Compare commits

..

25 Commits

Author SHA1 Message Date
gnanam1990
15e5d19f49 feat(repo-map): extract clean repo map branch 2026-04-14 18:57:46 +05:30
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
136 changed files with 10118 additions and 1324 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

@@ -4,9 +4,6 @@ on:
push: push:
branches: branches:
- main - main
release:
types:
- published
concurrency: concurrency:
group: auto-release-${{ github.ref }} group: auto-release-${{ github.ref }}
@@ -15,7 +12,6 @@ concurrency:
jobs: jobs:
release-please: release-please:
name: Release Please name: Release Please
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -34,7 +30,8 @@ jobs:
publish-npm: publish-npm:
name: Publish to npm name: Publish to npm
if: ${{ github.event_name == 'release' }} needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: release environment: release
permissions: permissions:
@@ -44,7 +41,7 @@ jobs:
- name: Checkout release tag - name: Checkout release tag
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with: with:
ref: ${{ github.event.release.tag_name }} ref: ${{ needs.release-please.outputs.tag_name }}
fetch-depth: 0 fetch-depth: 0
- name: Set up Node.js - name: Set up Node.js
@@ -84,8 +81,63 @@ jobs:
- name: Release summary - name: Release summary
run: | run: |
{ {
echo "## Released ${{ github.event.release.tag_name }}" echo "## Released ${{ needs.release-please.outputs.tag_name }}"
echo echo
echo "- npm: https://www.npmjs.com/package/@gitlawb/openclaude" echo "- npm: https://www.npmjs.com/package/@gitlawb/openclaude"
echo "- GitHub: https://github.com/Gitlawb/openclaude/releases/tag/${{ github.event.release.tag_name }}" echo "- GitHub: https://github.com/Gitlawb/openclaude/releases/tag/${{ needs.release-please.outputs.tag_name }}"
} >> "$GITHUB_STEP_SUMMARY" } >> "$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

View File

@@ -1,3 +1,3 @@
{ {
".": "0.2.1" ".": "0.3.0"
} }

View File

@@ -1,5 +1,45 @@
# Changelog # Changelog
## [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) ## [0.2.1](https://github.com/Gitlawb/openclaude/compare/v0.2.0...v0.2.1) (2026-04-12)

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# ---- 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 — many CLI tool operations depend on it
RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
# Run as non-root user
RUN groupadd --gid 1000 appuser && useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser
USER appuser
WORKDIR /home/appuser
ENV HOME=/home/appuser
ENTRYPOINT ["node", "/app/dist/cli.mjs"]

View File

@@ -2,7 +2,7 @@
OpenClaude is an open-source coding-agent CLI for cloud and local model providers. 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) [![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) [![Release](https://img.shields.io/github/v/tag/Gitlawb/openclaude?label=release&color=0ea5e9)](https://github.com/Gitlawb/openclaude/tags)
@@ -10,13 +10,16 @@ Use OpenAI-compatible APIs, Gemini, GitHub Models, Codex, Ollama, Atomic Chat, a
[![Security Policy](https://img.shields.io/badge/security-policy-0f766e)](SECURITY.md) [![Security Policy](https://img.shields.io/badge/security-policy-0f766e)](SECURITY.md)
[![License](https://img.shields.io/badge/license-MIT-2563eb)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-2563eb)](LICENSE)
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) | [Community](#community) [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)
## Why OpenClaude ## Why OpenClaude
- Use one CLI across cloud APIs and local model backends - Use one CLI across cloud APIs and local model backends
- Save provider profiles inside the app with `/provider` - 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 - 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 - Use the bundled VS Code extension for launch integration and theme support
@@ -105,7 +108,8 @@ 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 | | 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` | | 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 | | GitHub Models | `/onboard-github` | Interactive onboarding with saved credentials |
| Codex | `/provider` | Uses existing Codex credentials when available | | 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` or env vars | Local inference with no API key | | Ollama | `/provider` or env vars | Local inference with no API key |
| Atomic Chat | advanced setup | Local Apple Silicon backend | | Atomic Chat | advanced setup | Local Apple Silicon backend |
| Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments | | Bedrock / Vertex / Foundry | env vars | Additional provider integrations for supported environments |

View File

@@ -30,7 +30,7 @@
"@opentelemetry/semantic-conventions": "1.40.0", "@opentelemetry/semantic-conventions": "1.40.0",
"ajv": "8.18.0", "ajv": "8.18.0",
"auto-bind": "5.0.1", "auto-bind": "5.0.1",
"axios": "1.14.0", "axios": "1.15.0",
"bidi-js": "1.0.3", "bidi-js": "1.0.3",
"chalk": "5.6.2", "chalk": "5.6.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
@@ -479,7 +479,7 @@
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], "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=="], "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=="], "@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/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=="], "@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=="], "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=="], "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=="], "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. `codexplan` maps to GPT-5.4 on the Codex backend with high reasoning.
`codexspark` maps to GPT-5.3 Codex Spark for faster loops. `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`. 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 ```bash

67
docs/repo-map.md Normal file
View File

@@ -0,0 +1,67 @@
# Codebase Intelligence — Repo Map
The repo map feature gives the AI model structural awareness of your codebase at the start of each session. Instead of the model needing to explore the repository with `Grep`, `Glob`, and `Read` calls, it starts with a ranked summary of the most important files and their key signatures.
## How it works
1. **File enumeration** — Lists all tracked files via `git ls-files` (falls back to a manual directory walk when not in a git repo)
2. **Symbol extraction** — Parses each supported source file with tree-sitter to extract function, class, type, and interface definitions, plus cross-file references
3. **Reference graph** — Builds a directed graph where an edge from file A to file B means A references a symbol defined in B. Edges are weighted by reference count multiplied by the IDF (inverse document frequency) of the symbol name — common names like `get`, `set`, `value` contribute less
4. **PageRank** — Ranks files by structural importance using PageRank. Files imported by many others rank highest
5. **Rendering** — Walks ranked files top-down, emitting file paths and definition signatures, stopping when the token budget is reached
Results are cached to disk (`~/.openclaude/repomap-cache/`) keyed by file path, mtime, and size. Only changed files are re-parsed on subsequent runs.
## Supported languages
- TypeScript (`.ts`, `.tsx`)
- JavaScript (`.js`, `.jsx`, `.mjs`, `.cjs`)
- Python (`.py`)
Additional language grammars will be added in future releases.
## Enabling auto-injection
The repo map is gated behind the `REPO_MAP` feature flag, **off by default**. To enable auto-injection into the session context:
Set the environment variable before launching:
```bash
REPO_MAP=1 openclaude
```
Or add it to your shell profile for persistent use.
When enabled, the map is built once per session and prepended to the system context alongside git status and CLAUDE.md content. The default budget is 1024 tokens.
Auto-injection is skipped in:
- Bare mode (`--bare`)
- Remote sessions (`CLAUDE_CODE_REMOTE`)
## The /repomap slash command
The `/repomap` command is always available regardless of the feature flag. It lets you inspect and tune the map interactively.
```
/repomap # Show the map with default settings (1024 tokens)
/repomap --tokens 4096 # Increase the token budget for a larger map
/repomap --focus src/tools/ # Boost specific paths in the ranking
/repomap --focus src/context.ts # Can use multiple --focus flags
/repomap --stats # Show cache statistics
/repomap --invalidate # Clear cache and rebuild from scratch
```
## The RepoMap tool
The model can also call the `RepoMap` tool on demand during a session. This is useful when:
- The model needs structural context mid-conversation
- The user asks about specific areas (the model can pass `focus_files` or `focus_symbols`)
- A larger token budget is needed than the auto-injected default
## Known limitations
- **Signatures only** — The map shows function/class/type declarations, not implementations. The model still needs `Read` to see function bodies.
- **Cold build time** — First build on large repos (2000+ files) can take 20-30 seconds due to WASM-based parsing. Subsequent builds use the disk cache and complete in under 100ms.
- **Language coverage** — Only TypeScript, JavaScript, and Python are supported. Files in other languages are skipped.
- **TypeScript references** — The TypeScript tree-sitter query captures type annotations and `new` expressions as references, but not plain function calls. This means the ranking slightly favors type-heavy hub files.
- **Git dependency** — File enumeration uses `git ls-files` by default. Non-git repos fall back to a directory walk with hardcoded exclusions.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@gitlawb/openclaude", "name": "@gitlawb/openclaude",
"version": "0.2.1", "version": "0.3.0",
"description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models", "description": "Claude Code opened to any LLM — OpenAI, Gemini, DeepSeek, Ollama, and 200+ models",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -76,7 +76,7 @@
"@opentelemetry/semantic-conventions": "1.40.0", "@opentelemetry/semantic-conventions": "1.40.0",
"ajv": "8.18.0", "ajv": "8.18.0",
"auto-bind": "5.0.1", "auto-bind": "5.0.1",
"axios": "1.14.0", "axios": "1.15.0",
"bidi-js": "1.0.3", "bidi-js": "1.0.3",
"chalk": "5.6.2", "chalk": "5.6.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
@@ -95,8 +95,12 @@
"fuse.js": "7.1.0", "fuse.js": "7.1.0",
"get-east-asian-width": "1.5.0", "get-east-asian-width": "1.5.0",
"google-auth-library": "9.15.1", "google-auth-library": "9.15.1",
"graphology": "^0.26.0",
"graphology-operators": "^1.6.0",
"graphology-pagerank": "^1.1.0",
"https-proxy-agent": "7.0.6", "https-proxy-agent": "7.0.6",
"ignore": "7.0.5", "ignore": "7.0.5",
"js-tiktoken": "^1.0.16",
"indent-string": "5.0.0", "indent-string": "5.0.0",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"lodash-es": "4.18.1", "lodash-es": "4.18.1",
@@ -117,11 +121,13 @@
"strip-ansi": "7.2.0", "strip-ansi": "7.2.0",
"supports-hyperlinks": "3.2.0", "supports-hyperlinks": "3.2.0",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"tree-sitter-wasms": "^0.1.12",
"turndown": "7.2.2", "turndown": "7.2.2",
"type-fest": "4.41.0", "type-fest": "4.41.0",
"undici": "7.24.6", "undici": "7.24.6",
"usehooks-ts": "3.1.1", "usehooks-ts": "3.1.1",
"vscode-languageserver-protocol": "3.17.5", "vscode-languageserver-protocol": "3.17.5",
"web-tree-sitter": "^0.25.0",
"wrap-ansi": "9.0.2", "wrap-ansi": "9.0.2",
"ws": "8.20.0", "ws": "8.20.0",
"xss": "1.0.15", "xss": "1.0.15",

View File

@@ -8,7 +8,8 @@
* - src/ path aliases * - src/ path aliases
*/ */
import { readFileSync } from 'fs' import { readFileSync, readdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { noTelemetryPlugin } from './no-telemetry-plugin' import { noTelemetryPlugin } from './no-telemetry-plugin'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
@@ -24,25 +25,84 @@ const featureFlags: Record<string, boolean> = {
BRIDGE_MODE: false, BRIDGE_MODE: false,
DAEMON: false, DAEMON: false,
AGENT_TRIGGERS: false, AGENT_TRIGGERS: false,
MONITOR_TOOL: false, MONITOR_TOOL: true,
ABLATION_BASELINE: false, ABLATION_BASELINE: false,
DUMP_SYSTEM_PROMPT: false, DUMP_SYSTEM_PROMPT: false,
CACHED_MICROCOMPACT: false, CACHED_MICROCOMPACT: false,
COORDINATOR_MODE: false, COORDINATOR_MODE: true,
BUILTIN_EXPLORE_PLAN_AGENTS: true,
CONTEXT_COLLAPSE: false, CONTEXT_COLLAPSE: false,
COMMIT_ATTRIBUTION: false, COMMIT_ATTRIBUTION: false,
TEAMMEM: false, TEAMMEM: true,
UDS_INBOX: false, UDS_INBOX: false,
BG_SESSIONS: false, BG_SESSIONS: false,
AWAY_SUMMARY: false, AWAY_SUMMARY: false,
TRANSCRIPT_CLASSIFIER: false, TRANSCRIPT_CLASSIFIER: false,
WEB_BROWSER_TOOL: false, WEB_BROWSER_TOOL: false,
MESSAGE_ACTIONS: false, MESSAGE_ACTIONS: true,
BUDDY: true, BUDDY: true,
CHICAGO_MCP: false, CHICAGO_MCP: false,
COWORKER_TYPE_TELEMETRY: false, COWORKER_TYPE_TELEMETRY: false,
} }
// ── 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({ const result = await Bun.build({
entrypoints: ['./src/entrypoints/cli.tsx'], entrypoints: ['./src/entrypoints/cli.tsx'],
outdir: './dist', outdir: './dist',
@@ -103,18 +163,11 @@ export async function handleBgFlag() { throw new Error("Background sessions are
], ],
] as const) ] as const)
// Resolve `import { feature } from 'bun:bundle'` to a shim // bun:bundle feature() replacement is handled by the source
build.onResolve({ filter: /^bun:bundle$/ }, () => ({ // pre-processing step above (see preProcessFeatureFlags).
path: 'bun:bundle', // The previous onResolve/onLoad shim was ineffective in Bun
namespace: 'bun-bundle-shim', // v1.3.9+ because the bun: namespace is resolved natively
})) // before the JS plugin phase runs.
build.onLoad(
{ filter: /.*/, namespace: 'bun-bundle-shim' },
() => ({
contents: `const featureFlags = ${JSON.stringify(featureFlags)};\nexport function feature(name) { return featureFlags[name] ?? false; }`,
loader: 'js',
}),
)
build.onResolve( build.onResolve(
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ }, { filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
@@ -274,16 +327,7 @@ export const SeverityNumber = {};
// Scan source to find imports that can't resolve // Scan source to find imports that can't resolve
function scanForMissingImports() { function scanForMissingImports() {
function walk(dir: string) { function checkAndRegister(specifier: string, fileDir: string, namedPart: 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] || ''
const names = namedPart.split(',') const names = namedPart.split(',')
.map((s: string) => s.trim().replace(/^type\s+/, '')) .map((s: string) => s.trim().replace(/^type\s+/, ''))
.filter((s: string) => s && !s.startsWith('type ')) .filter((s: string) => s && !s.startsWith('type '))
@@ -303,8 +347,7 @@ export const SeverityNumber = {};
} }
// Check relative .js imports // Check relative .js imports
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) { else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
const dir2 = pathMod.dirname(full) const resolved = pathMod.resolve(fileDir, specifier)
const resolved = pathMod.resolve(dir2, specifier)
const tsVariant = resolved.replace(/\.js$/, '.ts') const tsVariant = resolved.replace(/\.js$/, '.ts')
const tsxVariant = resolved.replace(/\.js$/, '.tsx') const tsxVariant = resolved.replace(/\.js$/, '.tsx')
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) { if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
@@ -318,6 +361,30 @@ export const SeverityNumber = {};
for (const n of names) missingModuleExports.get(specifier)!.add(n) 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 code: string = fs.readFileSync(full, 'utf-8')
const fileDir = pathMod.dirname(full)
// 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, '')
}
} }
} }
walk(srcDir) walk(srcDir)
@@ -389,7 +456,13 @@ if (!result.success) {
for (const log of result.logs) { for (const log of result.logs) {
console.error(log) 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,146 @@
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({})
})
// ── 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,55 @@ export function _resetForTesting() {}
`, `,
'services/analytics/growthbook': ` 'services/analytics/growthbook': `
import _fs from 'node:fs';
import _path from 'node:path';
import _os from 'node:os';
let _flags = undefined;
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];
return defaultValue;
}
const noop = () => {}; const noop = () => {};
export function onGrowthBookRefresh() { return noop; } export function onGrowthBookRefresh() { return noop; }
export function hasGrowthBookEnvOverride() { return false; } export function hasGrowthBookEnvOverride() { return false; }
export function getAllGrowthBookFeatures() { return {}; } export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; }
export function getGrowthBookConfigOverrides() { return {}; } export function getGrowthBookConfigOverrides() { return {}; }
export function setGrowthBookConfigOverride() {} export function setGrowthBookConfigOverride() {}
export function clearGrowthBookConfigOverrides() {} export function clearGrowthBookConfigOverrides() {}
export function getApiBaseUrlHost() { return undefined; } export function getApiBaseUrlHost() { return undefined; }
export const initializeGrowthBook = async () => null; export const initializeGrowthBook = async () => null;
export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return defaultValue; } export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return defaultValue; } export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return defaultValue; } export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE() { return false; } export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(gate) { return Boolean(_getFlagValue(gate, false)); }
export async function checkSecurityRestrictionGate() { return false; } // Security killswitch — always false in the open build. Anthropic uses this
export async function checkGate_CACHED_OR_BLOCKING() { return false; } // 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 refreshGrowthBookAfterAuthChange() {}
export function resetGrowthBook() {} export function resetGrowthBook() { _flags = undefined; }
export async function refreshGrowthBookFeatures() {} export async function refreshGrowthBookFeatures() { _flags = undefined; }
export function setupPeriodicGrowthBookRefresh() {} export function setupPeriodicGrowthBookRefresh() {}
export function stopPeriodicGrowthBookRefresh() {} export function stopPeriodicGrowthBookRefresh() {}
export async function getDynamicConfig_BLOCKS_ON_INIT(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 defaultValue; } export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
`, `,
'services/analytics/sink': ` 'services/analytics/sink': `

View File

@@ -0,0 +1,282 @@
/**
* 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/)
})
})
// ---------------------------------------------------------------------------
// 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

@@ -1562,29 +1562,8 @@ export function clearInvokedSkillsForAgent(agentId: string): void {
} }
} }
// Slow operations tracking for dev bar // Slow operations tracking removed (was internal-only).
const MAX_SLOW_OPERATIONS = 10 // Functions kept as no-ops to avoid breaking callers.
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)
}
}
const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{ const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
operation: string operation: string
@@ -1592,33 +1571,18 @@ const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
timestamp: number timestamp: number
}> = [] }> = []
export function addSlowOperation(
_operation: string,
_durationMs: number,
): void {}
export function getSlowOperations(): ReadonlyArray<{ export function getSlowOperations(): ReadonlyArray<{
operation: string operation: string
durationMs: number durationMs: number
timestamp: 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 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
}
export function getMainThreadAgentType(): string | undefined { export function getMainThreadAgentType(): string | undefined {
return STATE.mainThreadAgentType return STATE.mainThreadAgentType

View File

@@ -14,21 +14,14 @@
import { getOauthConfig } from '../constants/oauth.js' import { getOauthConfig } from '../constants/oauth.js'
import { getClaudeAIOAuthTokens } from '../utils/auth.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 { export function getBridgeTokenOverride(): string | undefined {
return ( return process.env.CLAUDE_BRIDGE_OAUTH_TOKEN || undefined
(process.env.USER_TYPE === 'ant' &&
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 { export function getBridgeBaseUrlOverride(): string | undefined {
return ( return process.env.CLAUDE_BRIDGE_BASE_URL || undefined
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
undefined
)
} }
/** /**

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 // Session ingress URL for WebSocket connections. In production this is the
// same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress). // same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress).
// Locally, session-ingress runs on a different port (9413) than the // Locally, session-ingress may run on a different port, so
// contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be // CLAUDE_BRIDGE_SESSION_INGRESS_URL can override the default.
// set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
const sessionIngressUrl = const sessionIngressUrl =
process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
const { getBranch, getRemoteUrl, findGitRoot } = await import( const { getBranch, getRemoteUrl, findGitRoot } = await import(
'../utils/git.js' '../utils/git.js'
@@ -2851,10 +2847,7 @@ export async function runBridgeHeadless(
) )
} }
const sessionIngressUrl = const sessionIngressUrl =
process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
const { getBranch, getRemoteUrl, findGitRoot } = await import( const { getBranch, getRemoteUrl, findGitRoot } = await import(
'../utils/git.js' '../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 url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
const timeoutMs = 10_000
logForDebugging(`[bridge] Fetching session ${sessionId}`) logForDebugging(`[bridge] Fetching session ${sessionId}`)
let response let response
try { try {
response = await axios.get<{ environment_id?: string; title?: string }>( response = await axios.get<{ environment_id?: string; title?: string }>(
url, url,
{ headers, timeout: 10_000, validateStatus: s => s < 500 }, { headers, timeout: timeoutMs, validateStatus: s => s < 500 },
) )
} catch (err: unknown) { } catch (err: unknown) {
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( logForDebugging(
`[bridge] Session fetch request failed: ${errorMessage(err)}`, `[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 return null
} }
if (response.status !== 200) { if (response.status !== 200) {
const detail = extractErrorDetail(response.data) const detail = extractErrorDetail(response.data)
logForDebugging( 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 return null
} }

View File

@@ -465,10 +465,7 @@ export async function initReplBridge(
const branch = await getBranch() const branch = await getBranch()
const gitRepoUrl = await getRemoteUrl() const gitRepoUrl = await getRemoteUrl()
const sessionIngressUrl = const sessionIngressUrl =
process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
// Assistant-mode sessions advertise a distinct worker_type so the web UI // Assistant-mode sessions advertise a distinct worker_type so the web UI
// can filter them into a dedicated picker. KAIROS guard keeps the // can filter them into a dedicated picker. KAIROS guard keeps the

View File

@@ -362,15 +362,9 @@ const proactiveModule =
feature('PROACTIVE') || feature('KAIROS') feature('PROACTIVE') || feature('KAIROS')
? (require('../proactive/index.js') as typeof import('../proactive/index.js')) ? (require('../proactive/index.js') as typeof import('../proactive/index.js'))
: null : null
const cronSchedulerModule = feature('AGENT_TRIGGERS') const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')
? (require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js')) const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js')
: null const cronGate = require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js')
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 extractMemoriesModule = feature('EXTRACT_MEMORIES') const extractMemoriesModule = feature('EXTRACT_MEMORIES')
? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js'))
: null : null
@@ -2701,11 +2695,7 @@ function runHeadlessStreaming(
// the end of run() picks up the queued command. // the end of run() picks up the queued command.
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null = let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
null null
if ( if (cronGate.isKairosCronEnabled()) {
feature('AGENT_TRIGGERS') &&
cronSchedulerModule &&
cronGate?.isKairosCronEnabled()
) {
cronScheduler = cronSchedulerModule.createCronScheduler({ cronScheduler = cronSchedulerModule.createCronScheduler({
onFire: prompt => { onFire: prompt => {
if (inputClosed) return if (inputClosed) return
@@ -2727,8 +2717,8 @@ function runHeadlessStreaming(
void run() void run()
}, },
isLoading: () => running || inputClosed, isLoading: () => running || inputClosed,
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig, getJitterConfig: cronJitterConfigModule.getCronJitterConfig,
isKilled: () => !cronGate?.isKairosCronEnabled(), isKilled: () => !cronGate.isKairosCronEnabled(),
}) })
cronScheduler.start() cronScheduler.start()
} }
@@ -4592,7 +4582,7 @@ function handleSetPermissionMode(
subtype: 'error', subtype: 'error',
request_id: requestId, request_id: requestId,
error: 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 return toolPermissionContext

View File

@@ -22,6 +22,7 @@ import ctx_viz from './commands/ctx_viz/index.js'
import doctor from './commands/doctor/index.js' import doctor from './commands/doctor/index.js'
import onboardGithub from './commands/onboard-github/index.js' import onboardGithub from './commands/onboard-github/index.js'
import memory from './commands/memory/index.js' import memory from './commands/memory/index.js'
import repomap from './commands/repomap/index.js'
import help from './commands/help/index.js' import help from './commands/help/index.js'
import ide from './commands/ide/index.js' import ide from './commands/ide/index.js'
import init from './commands/init.js' import init from './commands/init.js'
@@ -307,6 +308,7 @@ const COMMANDS = memoize((): Command[] => [
releaseNotes, releaseNotes,
reloadPlugins, reloadPlugins,
rename, rename,
repomap,
resume, resume,
session, session,
skills, skills,

View File

@@ -1,17 +1,12 @@
import { execFileSync } from 'child_process' import { execFileSync } from 'child_process'
import { diffLines } from 'diff' import { diffLines } from 'diff'
import { constants as fsConstants } from 'fs'
import { import {
copyFile,
mkdir, mkdir,
mkdtemp,
readdir, readdir,
readFile, readFile,
rm,
unlink, unlink,
writeFile, writeFile,
} from 'fs/promises' } from 'fs/promises'
import { tmpdir } from 'os'
import { extname, join } from 'path' import { extname, join } from 'path'
import type { Command } from '../commands.js' import type { Command } from '../commands.js'
import { queryWithModel } from '../services/api/claude.js' import { queryWithModel } from '../services/api/claude.js'
@@ -22,7 +17,6 @@ import {
import type { LogOption } from '../types/logs.js' import type { LogOption } from '../types/logs.js'
import { getClaudeConfigHomeDir } from '../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
import { toError } from '../utils/errors.js' import { toError } from '../utils/errors.js'
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
import { logError } from '../utils/log.js' import { logError } from '../utils/log.js'
import { extractTextContent } from '../utils/messages.js' import { extractTextContent } from '../utils/messages.js'
import { getDefaultOpusModel } from '../utils/model/model.js' import { getDefaultOpusModel } from '../utils/model/model.js'
@@ -47,180 +41,6 @@ function getInsightsModel(): string {
return getDefaultOpusModel() 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 // Types
// ============================================================================ // ============================================================================
@@ -2659,7 +2479,6 @@ export type InsightsExport = {
claude_code_version: string claude_code_version: string
date_range: { start: string; end: string } date_range: { start: string; end: string }
session_count: number session_count: number
remote_hosts_collected?: string[]
} }
aggregated_data: AggregatedData aggregated_data: AggregatedData
insights: InsightResults insights: InsightResults
@@ -2680,14 +2499,9 @@ export function buildExportData(
data: AggregatedData, data: AggregatedData,
insights: InsightResults, insights: InsightResults,
facets: Map<string, SessionFacets>, facets: Map<string, SessionFacets>,
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number },
): InsightsExport { ): InsightsExport {
const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown' 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 = { const facets_summary = {
total: facets.size, total: facets.size,
goal_categories: {} as Record<string, number>, goal_categories: {} as Record<string, number>,
@@ -2725,10 +2539,6 @@ export function buildExportData(
claude_code_version: version, claude_code_version: version,
date_range: data.date_range, date_range: data.date_range,
session_count: data.total_sessions, session_count: data.total_sessions,
...(remote_hosts_collected &&
remote_hosts_collected.length > 0 && {
remote_hosts_collected,
}),
}, },
aggregated_data: data, aggregated_data: data,
insights, insights,
@@ -2793,24 +2603,12 @@ async function scanAllSessions(): Promise<LiteSessionInfo[]> {
// Main Function // Main Function
// ============================================================================ // ============================================================================
export async function generateUsageReport(options?: { export async function generateUsageReport(): Promise<{
collectRemote?: boolean
}): Promise<{
insights: InsightResults insights: InsightResults
htmlPath: string htmlPath: string
data: AggregatedData data: AggregatedData
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number }
facets: Map<string, SessionFacets> 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) // Phase 1: Lite scan — filesystem metadata only (no JSONL parsing)
const allScannedSessions = await scanAllSessions() const allScannedSessions = await scanAllSessions()
const totalSessionsScanned = allScannedSessions.length const totalSessionsScanned = allScannedSessions.length
@@ -3017,7 +2815,6 @@ export async function generateUsageReport(options?: {
insights, insights,
htmlPath, htmlPath,
data: aggregated, data: aggregated,
remoteStats,
facets: substantiveFacets, facets: substantiveFacets,
} }
} }
@@ -3043,31 +2840,8 @@ const usageReport: Command = {
contentLength: 0, // Dynamic content contentLength: 0, // Dynamic content
progressMessage: 'analyzing your sessions', progressMessage: 'analyzing your sessions',
source: 'builtin', source: 'builtin',
async getPromptForCommand(args) { async getPromptForCommand(_args) {
let collectRemote = false const { insights, htmlPath, data } = await generateUsageReport()
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 },
)
let reportUrl = `file://${htmlPath}` let reportUrl = `file://${htmlPath}`
let uploadHint = '' let uploadHint = ''
@@ -3085,20 +2859,6 @@ const usageReport: Command = {
`${data.git_commits} commits`, `${data.git_commits} commits`,
].join(' · ') ].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 // Build markdown summary from insights
const atAGlance = insights.at_a_glance const atAGlance = insights.at_a_glance
@@ -3118,7 +2878,6 @@ ${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitiou
${stats} ${stats}
${data.date_range.start} to ${data.date_range.end} ${data.date_range.start} to ${data.date_range.end}
${remoteInfo}
` `
const userSummary = `${header}${summaryText} const userSummary = `${header}${summaryText}

View File

@@ -1,20 +1,28 @@
import { PassThrough } from 'node:stream' import { PassThrough } from 'node:stream'
import { expect, test } from 'bun:test' import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react' import React from 'react'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { createRoot, render, useApp } from '../../ink.js' import { createRoot, render, useApp } from '../../ink.js'
import { AppStateProvider } from '../../state/AppState.js' import { AppStateProvider } from '../../state/AppState.js'
import { import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
buildCurrentProviderSummary, buildCurrentProviderSummary,
buildProfileSaveMessage, buildProfileSaveMessage,
getProviderWizardDefaults, getProviderWizardDefaults,
ProviderWizard,
TextEntryDialog, TextEntryDialog,
} from './provider.js' } from './provider.js'
import { createProfileFile } from '../../utils/providerProfile.js'
const SYNC_START = '\x1B[?2026h' const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l' 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 { function extractLastFrame(output: string): string {
let lastFrame: string | null = null let lastFrame: string | null = null
@@ -60,6 +68,51 @@ async function renderFinalFrame(node: React.ReactNode): Promise<string> {
return stripAnsi(extractLastFrame(getOutput())) 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(): { function createTestStreams(): {
stdout: PassThrough stdout: PassThrough
stdin: PassThrough & { stdin: PassThrough & {
@@ -94,6 +147,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 { function StepChangeHarness(): React.ReactNode {
const { exit } = useApp() const { exit } = useApp()
const [step, setStep] = React.useState<'api' | 'model'>('api') const [step, setStep] = React.useState<'api' | 'model'>('api')
@@ -233,6 +314,167 @@ test('buildProfileSaveMessage describes Gemini access token / ADC mode clearly',
expect(message).not.toContain('AIza') 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('applySavedProfileToCurrentSession switches the current env to the saved Codex profile', 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('codexplan')
expect(processEnv.OPENAI_BASE_URL).toBe(
'https://chatgpt.com/backend-api/codex',
)
expect(processEnv.CODEX_API_KEY).toBe('codex-live')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBe('acct_codex')
expect(processEnv.OPENAI_API_KEY).toBeUndefined()
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED).toBeUndefined()
expect(processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID).toBeUndefined()
})
test('applySavedProfileToCurrentSession ignores stale Codex env overrides for OAuth-backed profiles', 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).toBeNull()
expect(processEnv.OPENAI_MODEL).toBe('codexplan')
expect(processEnv.OPENAI_BASE_URL).toBe(
'https://chatgpt.com/backend-api/codex',
)
expect(processEnv.CODEX_API_KEY).toBeUndefined()
expect(processEnv.CHATGPT_ACCOUNT_ID).not.toBe('acct_stale')
expect(processEnv.CHATGPT_ACCOUNT_ID).toBeTruthy()
})
test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => { test('buildCurrentProviderSummary redacts poisoned model and endpoint values', () => {
const summary = buildCurrentProviderSummary({ const summary = buildCurrentProviderSummary({
processEnv: { processEnv: {
@@ -264,7 +506,7 @@ test('buildCurrentProviderSummary labels generic local openai-compatible provide
expect(summary.endpointLabel).toBe('http://127.0.0.1:8080/v1') 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({ const summary = buildCurrentProviderSummary({
processEnv: { processEnv: {
CLAUDE_CODE_USE_OPENAI: '1', CLAUDE_CODE_USE_OPENAI: '1',
@@ -307,3 +549,12 @@ test('getProviderWizardDefaults ignores poisoned current provider values', () =>
expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1') expect(defaults.openAIBaseUrl).toBe('https://api.openai.com/v1')
expect(defaults.geminiModel).toBe('gemini-2.0-flash') 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

@@ -10,8 +10,12 @@ import {
} from '../../components/CustomSelect/index.js' } from '../../components/CustomSelect/index.js'
import { Dialog } from '../../components/design-system/Dialog.js' import { Dialog } from '../../components/design-system/Dialog.js'
import { LoadingState } from '../../components/design-system/LoadingState.js' import { LoadingState } from '../../components/design-system/LoadingState.js'
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js' import { Box, Text } from '../../ink.js'
import {
type CodexOAuthTokens,
} from '../../services/api/codexOAuth.js'
import { import {
DEFAULT_CODEX_BASE_URL, DEFAULT_CODEX_BASE_URL,
DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_BASE_URL,
@@ -20,6 +24,8 @@ import {
resolveProviderRequest, resolveProviderRequest,
} from '../../services/api/providerConfig.js' } from '../../services/api/providerConfig.js'
import { import {
applySavedProfileToCurrentSession as applySharedProfileToCurrentSession,
buildCodexOAuthProfileEnv as buildSharedCodexOAuthProfileEnv,
buildCodexProfileEnv, buildCodexProfileEnv,
buildGeminiProfileEnv, buildGeminiProfileEnv,
buildMistralProfileEnv, buildMistralProfileEnv,
@@ -49,6 +55,7 @@ import {
readGeminiAccessToken, readGeminiAccessToken,
saveGeminiAccessToken, saveGeminiAccessToken,
} from '../../utils/geminiCredentials.js' } from '../../utils/geminiCredentials.js'
import { isBareMode } from '../../utils/envUtils.js'
import { import {
getGoalDefaultOpenAIModel, getGoalDefaultOpenAIModel,
normalizeRecommendationGoal, normalizeRecommendationGoal,
@@ -57,12 +64,13 @@ import {
type RecommendationGoal, type RecommendationGoal,
} from '../../utils/providerRecommendation.js' } from '../../utils/providerRecommendation.js'
import { import {
getOllamaChatBaseUrl,
getLocalOpenAICompatibleProviderLabel, getLocalOpenAICompatibleProviderLabel,
hasLocalOllama, hasLocalOllama,
listOllamaModels, listOllamaModels,
} from '../../utils/providerDiscovery.js' } from '../../utils/providerDiscovery.js'
type ProviderChoice = 'auto' | ProviderProfile | 'clear' type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
type Step = type Step =
| { name: 'choose' } | { name: 'choose' }
@@ -93,6 +101,7 @@ type Step =
apiKey?: string apiKey?: string
authMode: 'api-key' | 'access-token' | 'adc' authMode: 'api-key' | 'access-token' | 'adc'
} }
| { name: 'codex-oauth' }
| { name: 'codex-check' } | { name: 'codex-check' }
type CurrentProviderSummary = { type CurrentProviderSummary = {
@@ -131,6 +140,8 @@ type ProviderWizardDefaults = {
mistralBaseUrl: string mistralBaseUrl: string
} }
type SecretSourceEnv = NodeJS.ProcessEnv & Partial<ProfileEnv>
function isEnvTruthy(value: string | undefined): boolean { function isEnvTruthy(value: string | undefined): boolean {
if (!value) return false if (!value) return false
const normalized = value.trim().toLowerCase() const normalized = value.trim().toLowerCase()
@@ -139,7 +150,7 @@ function isEnvTruthy(value: string | undefined): boolean {
function getSafeDisplayValue( function getSafeDisplayValue(
value: string | undefined, value: string | undefined,
processEnv: NodeJS.ProcessEnv, processEnv: SecretSourceEnv,
profileEnv?: ProfileEnv, profileEnv?: ProfileEnv,
fallback = '(not set)', fallback = '(not set)',
): string { ): string {
@@ -151,14 +162,15 @@ function getSafeDisplayValue(
export function getProviderWizardDefaults( export function getProviderWizardDefaults(
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
): ProviderWizardDefaults { ): ProviderWizardDefaults {
const secretSource = processEnv as SecretSourceEnv
const safeOpenAIModel = const safeOpenAIModel =
sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.OPENAI_MODEL, secretSource) ||
'gpt-4o' 'gpt-4o'
const safeOpenAIBaseUrl = const safeOpenAIBaseUrl =
sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, processEnv) || sanitizeProviderConfigValue(processEnv.OPENAI_BASE_URL, secretSource) ||
DEFAULT_OPENAI_BASE_URL DEFAULT_OPENAI_BASE_URL
const safeGeminiModel = const safeGeminiModel =
sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.GEMINI_MODEL, secretSource) ||
DEFAULT_GEMINI_MODEL DEFAULT_GEMINI_MODEL
const safeMistralModel = const safeMistralModel =
sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) || sanitizeProviderConfigValue(processEnv.MISTRAL_MODEL, processEnv) ||
@@ -181,6 +193,7 @@ export function buildCurrentProviderSummary(options?: {
persisted?: ProfileFile | null persisted?: ProfileFile | null
}): CurrentProviderSummary { }): CurrentProviderSummary {
const processEnv = options?.processEnv ?? process.env const processEnv = options?.processEnv ?? process.env
const secretSource = processEnv as SecretSourceEnv
const persisted = options?.persisted ?? loadProfileFile() const persisted = options?.persisted ?? loadProfileFile()
const savedProfileLabel = persisted?.profile ?? 'none' const savedProfileLabel = persisted?.profile ?? 'none'
@@ -189,11 +202,11 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'Google Gemini', providerLabel: 'Google Gemini',
modelLabel: getSafeDisplayValue( modelLabel: getSafeDisplayValue(
processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL, processEnv.GEMINI_MODEL ?? DEFAULT_GEMINI_MODEL,
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL, processEnv.GEMINI_BASE_URL ?? DEFAULT_GEMINI_BASE_URL,
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -219,13 +232,13 @@ export function buildCurrentProviderSummary(options?: {
providerLabel: 'GitHub Models', providerLabel: 'GitHub Models',
modelLabel: getSafeDisplayValue( modelLabel: getSafeDisplayValue(
processEnv.OPENAI_MODEL ?? 'github:copilot', processEnv.OPENAI_MODEL ?? 'github:copilot',
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.OPENAI_BASE_URL ?? processEnv.OPENAI_BASE_URL ??
processEnv.OPENAI_API_BASE ?? processEnv.OPENAI_API_BASE ??
'https://models.github.ai/inference', 'https://models.github.ai/inference',
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -246,8 +259,8 @@ export function buildCurrentProviderSummary(options?: {
return { return {
providerLabel, providerLabel,
modelLabel: getSafeDisplayValue(request.requestedModel, processEnv), modelLabel: getSafeDisplayValue(request.requestedModel, secretSource),
endpointLabel: getSafeDisplayValue(request.baseUrl, processEnv), endpointLabel: getSafeDisplayValue(request.baseUrl, secretSource),
savedProfileLabel, savedProfileLabel,
} }
} }
@@ -258,11 +271,11 @@ export function buildCurrentProviderSummary(options?: {
processEnv.ANTHROPIC_MODEL ?? processEnv.ANTHROPIC_MODEL ??
processEnv.CLAUDE_MODEL ?? processEnv.CLAUDE_MODEL ??
'claude-sonnet-4-6', 'claude-sonnet-4-6',
processEnv, secretSource,
), ),
endpointLabel: getSafeDisplayValue( endpointLabel: getSafeDisplayValue(
processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com', processEnv.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com',
processEnv, secretSource,
), ),
savedProfileLabel, savedProfileLabel,
} }
@@ -376,6 +389,10 @@ export function buildProfileSaveMessage(
profile: ProviderProfile, profile: ProviderProfile,
env: ProfileEnv, env: ProfileEnv,
filePath: string, filePath: string,
options?: {
activatedInSession?: boolean
activationWarning?: string | null
},
): string { ): string {
const summary = buildSavedProfileSummary(profile, env) const summary = buildSavedProfileSummary(profile, env)
const lines = [ const lines = [
@@ -389,13 +406,24 @@ export function buildProfileSaveMessage(
} }
lines.push(`Profile: ${filePath}`) lines.push(`Profile: ${filePath}`)
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.') lines.push('Restart OpenClaude to use it.')
}
return lines.join('\n') return lines.join('\n')
} }
function buildUsageText(): string { function buildUsageText(): string {
const summary = buildCurrentProviderSummary() 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 [ return [
'Usage: /provider', 'Usage: /provider',
'', '',
@@ -406,7 +434,7 @@ function buildUsageText(): string {
`Current endpoint: ${summary.endpointLabel}`, `Current endpoint: ${summary.endpointLabel}`,
`Saved profile: ${summary.savedProfileLabel}`, `Saved profile: ${summary.savedProfileLabel}`,
'', '',
'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a profile for the next OpenClaude restart.', availableProviders,
].join('\n') ].join('\n')
} }
@@ -415,12 +443,45 @@ function finishProfileSave(
profile: ProviderProfile, profile: ProviderProfile,
env: ProfileEnv, env: ProfileEnv,
): void { ): 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 { try {
const profileFile = createProfileFile(profile, env) const profileFile = createProfileFile(profile, env)
const filePath = saveProfileFile(profileFile) const filePath = saveProfileFile(profileFile)
onDone(buildProfileSaveMessage(profile, env, filePath), { const shouldActivateInSession = profile === 'codex'
const activationWarning = shouldActivateInSession
? await applySharedProfileToCurrentSession({ profileFile })
: null
onDone(
buildProfileSaveMessage(profile, env, filePath, {
activatedInSession:
shouldActivateInSession && activationWarning === null,
activationWarning,
}),
{
display: 'system', display: 'system',
}) },
)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
onDone(`Failed to save provider profile: ${message}`, { onDone(`Failed to save provider profile: ${message}`, {
@@ -504,6 +565,10 @@ function ProviderChooser({
onCancel: () => void onCancel: () => void
}): React.ReactNode { }): React.ReactNode {
const summary = buildCurrentProviderSummary() 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>[] = [ const options: OptionWithDescription<ProviderChoice>[] = [
{ {
label: 'Auto', label: 'Auto',
@@ -537,6 +602,16 @@ function ProviderChooser({
value: 'codex', value: 'codex',
description: 'Use existing ChatGPT Codex CLI auth or env credentials', 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') { if (summary.savedProfileLabel !== 'none') {
@@ -554,10 +629,7 @@ function ProviderChooser({
onCancel={onCancel} onCancel={onCancel}
> >
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text> <Text>{helperText}</Text>
Save a provider profile for the next OpenClaude restart without
editing environment variables first.
</Text>
<Box flexDirection="column"> <Box flexDirection="column">
<Text dimColor>Current model: {summary.modelLabel}</Text> <Text dimColor>Current model: {summary.modelLabel}</Text>
<Text dimColor>Current endpoint: {summary.endpointLabel}</Text> <Text dimColor>Current endpoint: {summary.endpointLabel}</Text>
@@ -709,7 +781,9 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -732,7 +806,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'continue') { if (value === 'continue') {
onNeedOpenAI(status.defaultModel) onNeedOpenAI(status.defaultModel)
} else if (value === 'back') { } else if (value === 'back') {
@@ -765,7 +839,7 @@ function AutoRecommendationStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'save') { if (value === 'save') {
onSave( onSave(
'ollama', 'ollama',
@@ -867,7 +941,9 @@ function OllamaModelStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -888,7 +964,7 @@ function OllamaModelStep({
defaultFocusValue={status.defaultValue} defaultFocusValue={status.defaultValue}
inlineDescriptions inlineDescriptions
visibleOptionCount={Math.min(8, status.options.length)} visibleOptionCount={Math.min(8, status.options.length)}
onChange={value => { onChange={(value: string) => {
onSave( onSave(
'ollama', 'ollama',
buildOllamaProfileEnv(value, { buildOllamaProfileEnv(value, {
@@ -903,6 +979,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({ function CodexCredentialStep({
onSave, onSave,
onBack, onBack,
@@ -924,7 +1078,9 @@ function CodexCredentialStep({
{ label: 'Back', value: 'back' }, { label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' }, { label: 'Cancel', value: 'cancel' },
]} ]}
onChange={value => (value === 'back' ? onBack() : onCancel())} onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel} onCancel={onCancel}
/> />
</Box> </Box>
@@ -958,9 +1114,10 @@ function CodexCredentialStep({
defaultFocusValue="codexplan" defaultFocusValue="codexplan"
inlineDescriptions inlineDescriptions
visibleOptionCount={options.length} visibleOptionCount={options.length}
onChange={value => { onChange={(value: string) => {
const env = buildCodexProfileEnv({ const env = buildCodexProfileEnv({
model: value, model: value,
credentialSource: credentials.credentialSource,
processEnv: process.env, processEnv: process.env,
}) })
if (env) { if (env) {
@@ -975,9 +1132,16 @@ function CodexCredentialStep({
} }
function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv): function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
| { ok: true; sourceDescription: string } | {
ok: true
sourceDescription: string
credentialSource: 'oauth' | 'existing'
}
| { ok: false; message: string } { | { ok: false; message: string } {
const credentials = resolveCodexApiCredentials(processEnv) 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) { if (!credentials.apiKey) {
const authHint = credentials.authPath const authHint = credentials.authPath
@@ -985,7 +1149,7 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
: 'Set CODEX_API_KEY or re-login with the Codex CLI.' : 'Set CODEX_API_KEY or re-login with the Codex CLI.'
return { return {
ok: false, 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}`,
} }
} }
@@ -993,15 +1157,19 @@ function resolveCodexCredentials(processEnv: NodeJS.ProcessEnv):
return { return {
ok: false, ok: false,
message: 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 { return {
ok: true, ok: true,
credentialSource:
credentials.source === 'secure-storage' ? 'oauth' : 'existing',
sourceDescription: sourceDescription:
credentials.source === 'env' credentials.source === 'env'
? 'the current shell environment' ? 'the current shell environment'
: credentials.source === 'secure-storage'
? 'OpenClaude secure storage'
: credentials.authPath ?? DEFAULT_CODEX_BASE_URL, : credentials.authPath ?? DEFAULT_CODEX_BASE_URL,
} }
} }
@@ -1035,6 +1203,8 @@ export function ProviderWizard({
name: 'mistral-key', name: 'mistral-key',
defaultModel: defaults.mistralModel, defaultModel: defaults.mistralModel,
}) })
} else if (value === 'codex-oauth') {
setStep({ name: 'codex-oauth' })
} else if (value === 'clear') { } else if (value === 'clear') {
const filePath = deleteProfileFile() const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, { onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
@@ -1314,7 +1484,7 @@ export function ProviderWizard({
options={options} options={options}
inlineDescriptions inlineDescriptions
visibleOptionCount={options.length} visibleOptionCount={options.length}
onChange={value => { onChange={(value: string) => {
if (value === 'api-key') { if (value === 'api-key') {
setStep({ name: 'gemini-key' }) setStep({ name: 'gemini-key' })
} else if (value === 'access-token') { } else if (value === 'access-token') {
@@ -1470,6 +1640,15 @@ export function ProviderWizard({
onCancel={() => onDone()} onCancel={() => onDone()}
/> />
) )
case 'codex-oauth':
return (
<CodexOAuthStep
onSave={(profile, env) => finishProfileSave(onDone, profile, env)}
onBack={() => setStep({ name: 'choose' })}
onCancel={() => onDone()}
/>
)
} }
} }

View File

@@ -0,0 +1,17 @@
/**
* /repomap command - minimal metadata only.
* Implementation is lazy-loaded from repomap.ts to reduce startup time.
*/
import type { Command } from '../../commands.js'
const repomap = {
type: 'local',
name: 'repomap',
description:
'Show or configure the repository structural map (codebase intelligence)',
isHidden: false,
supportsNonInteractive: true,
load: () => import('./repomap.js'),
} satisfies Command
export default repomap

View File

@@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test'
import { parseArgs } from './repomap.js'
describe('/repomap argument parsing', () => {
test('defaults to 1024 tokens with no flags', () => {
const result = parseArgs('')
expect(result.tokens).toBe(2048)
expect(result.focus).toEqual([])
expect(result.invalidate).toBe(false)
expect(result.stats).toBe(false)
})
test('parses --tokens flag', () => {
const result = parseArgs('--tokens 4096')
expect(result.tokens).toBe(4096)
})
test('rejects --tokens below 256', () => {
const result = parseArgs('--tokens 100')
expect(result.tokens).toBe(2048) // falls back to default
})
test('rejects --tokens above 16384', () => {
const result = parseArgs('--tokens 20000')
expect(result.tokens).toBe(2048) // falls back to default
})
test('parses --focus flag', () => {
const result = parseArgs('--focus src/tools/')
expect(result.focus).toEqual(['src/tools/'])
})
test('parses multiple --focus flags', () => {
const result = parseArgs('--focus src/tools/ --focus src/context.ts')
expect(result.focus).toEqual(['src/tools/', 'src/context.ts'])
})
test('parses --invalidate flag', () => {
const result = parseArgs('--invalidate')
expect(result.invalidate).toBe(true)
expect(result.stats).toBe(false)
})
test('parses --stats flag', () => {
const result = parseArgs('--stats')
expect(result.stats).toBe(true)
expect(result.invalidate).toBe(false)
})
test('parses combined flags', () => {
const result = parseArgs('--tokens 2048 --focus src/tools/ --invalidate')
expect(result.tokens).toBe(2048)
expect(result.focus).toEqual(['src/tools/'])
expect(result.invalidate).toBe(true)
})
})

View File

@@ -0,0 +1,93 @@
import type { LocalCommandCall } from '../../types/command.js'
import { getCwd } from '../../utils/cwd.js'
/** Parse CLI-style arguments from the command string. */
export function parseArgs(args: string): {
tokens: number
focus: string[]
invalidate: boolean
stats: boolean
} {
const parts = args.trim().split(/\s+/).filter(Boolean)
let tokens = 2048
const focus: string[] = []
let invalidate = false
let stats = false
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!
if (part === '--tokens' && i + 1 < parts.length) {
const n = parseInt(parts[i + 1]!, 10)
if (!isNaN(n) && n >= 256 && n <= 16384) {
tokens = n
}
i++
} else if (part === '--focus' && i + 1 < parts.length) {
focus.push(parts[i + 1]!)
i++
} else if (part === '--invalidate') {
invalidate = true
} else if (part === '--stats') {
stats = true
}
}
return { tokens, focus, invalidate, stats }
}
export const call: LocalCommandCall = async (args) => {
const root = getCwd()
const { tokens, focus, invalidate, stats } = parseArgs(args ?? '')
// Lazy import to avoid loading tree-sitter at startup
const {
buildRepoMap,
invalidateCache,
getCacheStats,
} = await import('../../context/repoMap/index.js')
if (stats) {
const cacheStats = getCacheStats(root)
const lines = [
`Repository map cache stats:`,
` Cache directory: ${cacheStats.cacheDir}`,
` Cache file: ${cacheStats.cacheFile ?? '(none)'}`,
` Cached entries: ${cacheStats.entryCount}`,
` Cache exists: ${cacheStats.exists}`,
]
return { type: 'text', value: lines.join('\n') }
}
if (invalidate) {
invalidateCache(root)
const result = await buildRepoMap({
root,
maxTokens: tokens,
focusFiles: focus.length > 0 ? focus : undefined,
})
return {
type: 'text',
value: [
`Cache invalidated and rebuilt.`,
`Files: ${result.fileCount} ranked (${result.totalFileCount} total) | Tokens: ${result.tokenCount} | Time: ${result.buildTimeMs}ms | Cache hit: ${result.cacheHit}`,
'',
result.map,
].join('\n'),
}
}
const result = await buildRepoMap({
root,
maxTokens: tokens,
focusFiles: focus.length > 0 ? focus : undefined,
})
return {
type: 'text',
value: [
`Repository map: ${result.fileCount} files ranked (${result.totalFileCount} total) | Tokens: ${result.tokenCount} | Time: ${result.buildTimeMs}ms | Cache hit: ${result.cacheHit}`,
'',
result.map,
].join('\n'),
}
}

View File

@@ -101,7 +101,7 @@ export function EffortPicker({ onSelect, onCancel }: Props) {
<Box marginBottom={1} flexDirection="column"> <Box marginBottom={1} flexDirection="column">
<Text color="remember" bold={true}>Set effort level</Text> <Text color="remember" bold={true}>Set effort level</Text>
<Text dimColor={true}> <Text dimColor={true}>
{usesOpenAIEffort {supportsEffort && usesOpenAIEffort
? `OpenAI/Codex provider (${provider})` ? `OpenAI/Codex provider (${provider})`
: supportsEffort : supportsEffort
? `Claude model · ${provider} provider` ? `Claude model · ${provider} provider`

View File

@@ -5,13 +5,14 @@ import React from 'react'
import stripAnsi from 'strip-ansi' import stripAnsi from 'strip-ansi'
import { createRoot } from '../ink.js' import { createRoot } from '../ink.js'
import { AppStateProvider } from '../state/AppState.js'
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
import { AppStateProvider } from '../state/AppState.js'
const SYNC_START = '\x1B[?2026h' const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l' const SYNC_END = '\x1B[?2026l'
const ORIGINAL_ENV = { const ORIGINAL_ENV = {
CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
GITHUB_TOKEN: process.env.GITHUB_TOKEN, GITHUB_TOKEN: process.env.GITHUB_TOKEN,
GH_TOKEN: process.env.GH_TOKEN, GH_TOKEN: process.env.GH_TOKEN,
@@ -109,6 +110,9 @@ function createDeferred<T>(): {
function mockProviderProfilesModule(options?: { function mockProviderProfilesModule(options?: {
addProviderProfile?: (...args: unknown[]) => unknown addProviderProfile?: (...args: unknown[]) => unknown
getProviderProfiles?: () => unknown[]
updateProviderProfile?: (...args: unknown[]) => unknown
setActiveProviderProfile?: (...args: unknown[]) => unknown
}): void { }): void {
mock.module('../utils/providerProfiles.js', () => ({ mock.module('../utils/providerProfiles.js', () => ({
addProviderProfile: options?.addProviderProfile ?? (() => null), addProviderProfile: options?.addProviderProfile ?? (() => null),
@@ -131,17 +135,20 @@ function mockProviderProfilesModule(options?: {
model: 'mock-model', model: 'mock-model',
apiKey: '', apiKey: '',
}, },
getProviderProfiles: () => [], getProviderProfiles: options?.getProviderProfiles ?? (() => []),
setActiveProviderProfile: () => null, setActiveProviderProfile: options?.setActiveProviderProfile ?? (() => null),
updateProviderProfile: () => null, updateProviderProfile: options?.updateProviderProfile ?? (() => null),
})) }))
} }
function mockProviderManagerDependencies( function mockProviderManagerDependencies(
syncRead: () => string | undefined, githubSyncRead: () => string | undefined,
asyncRead: () => Promise<string | undefined>, githubAsyncRead: () => Promise<string | undefined>,
options?: { options?: {
addProviderProfile?: (...args: unknown[]) => unknown addProviderProfile?: (...args: unknown[]) => unknown
applySavedProfileToCurrentSession?: (...args: unknown[]) => Promise<string | null>
clearCodexCredentials?: () => { success: boolean; warning?: string }
getProviderProfiles?: () => unknown[]
hasLocalOllama?: () => Promise<boolean> hasLocalOllama?: () => Promise<boolean>
listOllamaModels?: () => Promise< listOllamaModels?: () => Promise<
Array<{ Array<{
@@ -153,9 +160,33 @@ function mockProviderManagerDependencies(
quantizationLevel?: string | null quantizationLevel?: string | null
}> }>
> >
codexSyncRead?: () => unknown
codexAsyncRead?: () => Promise<unknown>
updateProviderProfile?: (...args: unknown[]) => unknown
setActiveProviderProfile?: (...args: unknown[]) => unknown
useCodexOAuthFlow?: (options: {
onAuthenticated: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) =>
void | Promise<void>
}) => {
state: 'starting' | 'waiting' | 'error'
authUrl?: string
browserOpened?: boolean | null
message?: string
}
}, },
): void { ): void {
mockProviderProfilesModule({ addProviderProfile: options?.addProviderProfile }) mockProviderProfilesModule({
addProviderProfile: options?.addProviderProfile,
getProviderProfiles: options?.getProviderProfiles,
updateProviderProfile: options?.updateProviderProfile,
setActiveProviderProfile: options?.setActiveProviderProfile,
})
mock.module('../utils/providerDiscovery.js', () => ({ mock.module('../utils/providerDiscovery.js', () => ({
hasLocalOllama: options?.hasLocalOllama ?? (async () => false), hasLocalOllama: options?.hasLocalOllama ?? (async () => false),
@@ -166,13 +197,65 @@ function mockProviderManagerDependencies(
clearGithubModelsToken: () => ({ success: true }), clearGithubModelsToken: () => ({ success: true }),
GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED', GITHUB_MODELS_HYDRATED_ENV_MARKER: 'CLAUDE_CODE_GITHUB_TOKEN_HYDRATED',
hydrateGithubModelsTokenFromSecureStorage: () => {}, hydrateGithubModelsTokenFromSecureStorage: () => {},
readGithubModelsToken: syncRead, readGithubModelsToken: githubSyncRead,
readGithubModelsTokenAsync: asyncRead, readGithubModelsTokenAsync: githubAsyncRead,
}))
mock.module('../utils/codexCredentials.js', () => ({
attachCodexProfileIdToStoredCredentials: () => ({ success: true }),
clearCodexCredentials:
options?.clearCodexCredentials ?? (() => ({ success: true })),
readCodexCredentials:
options?.codexSyncRead ?? (() => undefined),
readCodexCredentialsAsync:
options?.codexAsyncRead ?? (async () => undefined),
}))
mock.module('../utils/providerProfile.js', () => ({
applySavedProfileToCurrentSession:
options?.applySavedProfileToCurrentSession ?? (async () => null),
buildCodexOAuthProfileEnv: (tokens: {
accessToken: string
accountId?: string
idToken?: string
}) => {
const accountId =
tokens.accountId ??
(tokens.idToken ? 'acct_from_id_token' : undefined) ??
(tokens.accessToken ? 'acct_from_access_token' : undefined)
if (!accountId) {
return null
}
return {
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
OPENAI_MODEL: 'codexplan',
CHATGPT_ACCOUNT_ID: accountId,
CODEX_CREDENTIAL_SOURCE: 'oauth' as const,
}
},
clearPersistedCodexOAuthProfile: () => null,
createProfileFile: (profile: string, env: Record<string, unknown>) => ({
profile,
env,
createdAt: '2026-04-10T00:00:00.000Z',
}),
})) }))
mock.module('../utils/settings/settings.js', () => ({ mock.module('../utils/settings/settings.js', () => ({
updateSettingsForSource: () => ({ error: null }), updateSettingsForSource: () => ({ error: null }),
})) }))
mock.module('./useCodexOAuthFlow.js', () => ({
useCodexOAuthFlow:
options?.useCodexOAuthFlow ??
(() => ({
state: 'waiting' as const,
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
})),
}))
} }
async function waitForFrameOutput( async function waitForFrameOutput(
@@ -240,9 +323,9 @@ async function renderProviderManagerFrame(
onDone: (result?: unknown) => void onDone: (result?: unknown) => void
}>, }>,
options?: { options?: {
mode?: 'first-run' | 'manage'
waitForOutput?: (output: string) => boolean waitForOutput?: (output: string) => boolean
timeoutMs?: number timeoutMs?: number
mode?: 'first-run' | 'manage'
}, },
): Promise<string> { ): Promise<string> {
const mounted = await mountProviderManager(ProviderManager, { const mounted = await mountProviderManager(ProviderManager, {
@@ -305,6 +388,47 @@ test('ProviderManager resolves GitHub virtual provider from async storage withou
expect(asyncRead).toHaveBeenCalled() expect(asyncRead).toHaveBeenCalled()
}) })
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const syncRead = mock(() => {
throw new Error('sync credential read should not run in ProviderManager render flow')
})
const deferredStoredToken = createDeferred<string | undefined>()
const asyncRead = mock(async () => deferredStoredToken.promise)
mockProviderManagerDependencies(syncRead, asyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager)
const firstFrame = await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Provider manager'),
)
expect(firstFrame).toContain('Checking GitHub Models credentials...')
expect(firstFrame).not.toContain('No provider profiles configured yet.')
deferredStoredToken.resolve('stored-token')
const resolvedFrame = await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('GitHub Models') && frame.includes('token stored'),
)
expect(resolvedFrame).toContain('GitHub Models')
expect(resolvedFrame).toContain('token stored')
await mounted.dispose()
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled()
})
test('ProviderManager first-run Ollama preset auto-detects installed models', async () => { test('ProviderManager first-run Ollama preset auto-detects installed models', async () => {
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN delete process.env.GITHUB_TOKEN
@@ -395,43 +519,411 @@ test('ProviderManager first-run Ollama preset auto-detects installed models', as
await mounted.dispose() await mounted.dispose()
}) })
test('ProviderManager avoids first-frame false negative while stored-token lookup is pending', async () => { test('ProviderManager first-run Codex OAuth switches the current session after login completes', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN delete process.env.GH_TOKEN
const syncRead = mock(() => { const onDone = mock(() => {})
throw new Error('sync credential read should not run in ProviderManager render flow') const applySavedProfileToCurrentSession = mock(async () => null)
}) const persistCredentials = mock(() => {})
const deferredStoredToken = createDeferred<string | undefined>() const addProviderProfile = mock((payload: {
const asyncRead = mock(async () => deferredStoredToken.promise) provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
mockProviderManagerDependencies(syncRead, asyncRead) mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
applySavedProfileToCurrentSession,
useCodexOAuthFlow: ({ onAuthenticated }) => {
React.useEffect(() => {
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'codexplan',
apiKey: '',
}),
expect.objectContaining({ makeActive: true }),
)
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
expect(onDone).toHaveBeenCalledWith(
expect.objectContaining({
action: 'saved',
message:
'Codex OAuth configured. OpenClaude switched to it for this session.',
}),
)
await mounted.dispose()
})
test('ProviderManager first-run Codex OAuth reports next-startup fallback when session activation fails', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const applySavedProfileToCurrentSession = mock(
async () => 'validation failed',
)
const persistCredentials = mock(() => {})
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
applySavedProfileToCurrentSession,
useCodexOAuthFlow: ({ onAuthenticated }) => {
React.useEffect(() => {
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
expect(onDone).toHaveBeenCalledWith(
expect.objectContaining({
action: 'saved',
message:
'Codex OAuth configured. Saved for next startup. Warning: validation failed.',
}),
)
await mounted.dispose()
})
test('ProviderManager does not hijack a manual Codex profile when OAuth credentials are not yet linked', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const onDone = mock(() => {})
const manualProfile = {
id: 'provider_manual_codex',
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'gpt-5.4',
apiKey: 'manual-key',
}
const addProviderProfile = mock((payload: {
provider: string
name: string
baseUrl: string
model: string
apiKey?: string
}) => ({
id: 'provider_codex_oauth',
provider: payload.provider,
name: payload.name,
baseUrl: payload.baseUrl,
model: payload.model,
apiKey: payload.apiKey,
}))
const updateProviderProfile = mock(() => manualProfile)
const persistCredentials = mock(() => {})
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
addProviderProfile,
getProviderProfiles: () => [manualProfile],
updateProviderProfile,
useCodexOAuthFlow: ({ onAuthenticated }) => {
const hasAuthenticated = React.useRef(false)
React.useEffect(() => {
if (hasAuthenticated.current) {
return
}
hasAuthenticated.current = true
void onAuthenticated({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
}, persistCredentials)
}, [onAuthenticated])
return {
state: 'waiting',
authUrl: 'https://chatgpt.com/codex',
browserOpened: true,
}
},
},
)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager, {
mode: 'first-run',
onDone,
})
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Set up provider') && frame.includes('Codex OAuth'),
)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('j')
await Bun.sleep(25)
mounted.stdin.write('\r')
await waitForCondition(() => onDone.mock.calls.length > 0)
expect(addProviderProfile).toHaveBeenCalledTimes(1)
expect(updateProviderProfile).not.toHaveBeenCalled()
expect(persistCredentials).toHaveBeenCalledWith({
profileId: 'provider_codex_oauth',
})
await mounted.dispose()
})
test('ProviderManager keeps Codex OAuth as next-startup only when activating the session fails from the menu', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const codexProfile = {
id: 'provider_codex_oauth',
provider: 'openai',
name: 'Codex OAuth',
baseUrl: 'https://chatgpt.com/backend-api/codex',
model: 'codexplan',
apiKey: '',
}
const applySavedProfileToCurrentSession = mock(
async () => 'validation failed',
)
const setActiveProviderProfile = mock(() => codexProfile)
mockProviderManagerDependencies(
() => undefined,
async () => undefined,
{
applySavedProfileToCurrentSession,
getProviderProfiles: () => [codexProfile],
setActiveProviderProfile,
codexAsyncRead: async () => ({
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
profileId: 'provider_codex_oauth',
}),
},
)
const nonce = `${Date.now()}-${Math.random()}` const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`) const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const mounted = await mountProviderManager(ProviderManager) const mounted = await mountProviderManager(ProviderManager)
const firstFrame = await waitForFrameOutput( await waitForFrameOutput(
mounted.getOutput, mounted.getOutput,
frame => frame.includes('Provider manager'), frame =>
frame.includes('Provider manager') &&
frame.includes('Set active provider') &&
frame.includes('Log out Codex OAuth'),
) )
expect(firstFrame).toContain('Checking GitHub Models credentials...') mounted.stdin.write('j')
expect(firstFrame).not.toContain('No provider profiles configured yet.') await Bun.sleep(25)
mounted.stdin.write('\r')
deferredStoredToken.resolve('stored-token') await waitForFrameOutput(
const resolvedFrame = await waitForFrameOutput(
mounted.getOutput, mounted.getOutput,
frame => frame.includes('GitHub Models') && frame.includes('token stored'), frame => frame.includes('Set active provider') && frame.includes('Codex OAuth'),
) )
expect(resolvedFrame).toContain('GitHub Models') await Bun.sleep(25)
expect(resolvedFrame).toContain('token stored') mounted.stdin.write('\r')
await waitForCondition(() => setActiveProviderProfile.mock.calls.length > 0)
await waitForCondition(
() => applySavedProfileToCurrentSession.mock.calls.length > 0,
)
await Bun.sleep(50)
const output = stripAnsi(extractLastFrame(mounted.getOutput()))
expect(output).toContain(
'Active provider: Codex OAuth. Saved for next startup. Warning: validation failed.',
)
expect(applySavedProfileToCurrentSession).toHaveBeenCalled()
expect(setActiveProviderProfile).toHaveBeenCalledWith('provider_codex_oauth')
await mounted.dispose() await mounted.dispose()
})
expect(syncRead).not.toHaveBeenCalled()
expect(asyncRead).toHaveBeenCalled() test('ProviderManager resolves Codex OAuth state from async storage without sync reads in render flow', async () => {
delete process.env.CLAUDE_CODE_SIMPLE
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const githubSyncRead = mock(() => undefined)
const githubAsyncRead = mock(async () => undefined)
const codexSyncRead = mock(() => {
throw new Error('sync codex credential read should not run in ProviderManager render flow')
})
const codexAsyncRead = mock(async () => ({
accessToken: 'codex-access-token',
refreshToken: 'codex-refresh-token',
}))
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead, {
codexSyncRead,
codexAsyncRead,
})
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const output = await renderProviderManagerFrame(ProviderManager, {
waitForOutput: frame =>
frame.includes('Provider manager') &&
frame.includes('Log out Codex OAuth'),
})
expect(output).toContain('Provider manager')
expect(output).toContain('Log out Codex OAuth')
expect(codexSyncRead).not.toHaveBeenCalled()
expect(codexAsyncRead).toHaveBeenCalled()
})
test('ProviderManager hides Codex OAuth setup in bare mode', async () => {
process.env.CLAUDE_CODE_SIMPLE = '1'
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env.GITHUB_TOKEN
delete process.env.GH_TOKEN
const githubSyncRead = mock(() => undefined)
const githubAsyncRead = mock(async () => undefined)
mockProviderManagerDependencies(githubSyncRead, githubAsyncRead)
const nonce = `${Date.now()}-${Math.random()}`
const { ProviderManager } = await import(`./ProviderManager.js?ts=${nonce}`)
const output = await renderProviderManagerFrame(ProviderManager, {
mode: 'first-run',
waitForOutput: frame =>
frame.includes('Set up provider') && frame.includes('OpenAI'),
})
expect(output).toContain('Set up provider')
expect(output).not.toContain('Codex OAuth')
}) })

View File

@@ -1,9 +1,20 @@
import figures from 'figures' import figures from 'figures'
import * as React from 'react' import * as React from 'react'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
import { Box, Text } from '../ink.js' import { Box, Text } from '../ink.js'
import { useKeybinding } from '../keybindings/useKeybinding.js' import { useKeybinding } from '../keybindings/useKeybinding.js'
import type { ProviderProfile } from '../utils/config.js' import type { ProviderProfile } from '../utils/config.js'
import { hasLocalOllama, listOllamaModels } from '../utils/providerDiscovery.js' import {
clearCodexCredentials,
readCodexCredentialsAsync,
} from '../utils/codexCredentials.js'
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
clearPersistedCodexOAuthProfile,
createProfileFile,
} from '../utils/providerProfile.js'
import { import {
addProviderProfile, addProviderProfile,
applyActiveProviderProfileFromConfig, applyActiveProviderProfileFromConfig,
@@ -16,10 +27,6 @@ import {
type ProviderProfileInput, type ProviderProfileInput,
updateProviderProfile, updateProviderProfile,
} from '../utils/providerProfiles.js' } from '../utils/providerProfiles.js'
import {
rankOllamaModels,
recommendOllamaModel,
} from '../utils/providerRecommendation.js'
import { import {
clearGithubModelsToken, clearGithubModelsToken,
GITHUB_MODELS_HYDRATED_ENV_MARKER, GITHUB_MODELS_HYDRATED_ENV_MARKER,
@@ -27,11 +34,22 @@ import {
readGithubModelsToken, readGithubModelsToken,
readGithubModelsTokenAsync, readGithubModelsTokenAsync,
} from '../utils/githubModelsCredentials.js' } from '../utils/githubModelsCredentials.js'
import { isEnvTruthy } from '../utils/envUtils.js' import {
hasLocalOllama,
listOllamaModels,
} from '../utils/providerDiscovery.js'
import {
rankOllamaModels,
recommendOllamaModel,
} from '../utils/providerRecommendation.js'
import { updateSettingsForSource } from '../utils/settings/settings.js' import { updateSettingsForSource } from '../utils/settings/settings.js'
import { type OptionWithDescription, Select } from './CustomSelect/index.js' import {
type OptionWithDescription,
Select,
} from './CustomSelect/index.js'
import { Pane } from './design-system/Pane.js' import { Pane } from './design-system/Pane.js'
import TextInput from './TextInput.js' import TextInput from './TextInput.js'
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
export type ProviderManagerResult = { export type ProviderManagerResult = {
action: 'saved' | 'cancelled' action: 'saved' | 'cancelled'
@@ -48,6 +66,7 @@ type Screen =
| 'menu' | 'menu'
| 'select-preset' | 'select-preset'
| 'select-ollama-model' | 'select-ollama-model'
| 'codex-oauth'
| 'form' | 'form'
| 'select-active' | 'select-active'
| 'select-edit' | 'select-edit'
@@ -105,6 +124,8 @@ const GITHUB_PROVIDER_ID = '__github_models__'
const GITHUB_PROVIDER_LABEL = 'GitHub Models' const GITHUB_PROVIDER_LABEL = 'GitHub Models'
const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot' const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference' const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference'
const CODEX_OAUTH_PROVIDER_NAME = 'Codex OAuth'
const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan'
type GithubCredentialSource = 'stored' | 'env' | 'none' type GithubCredentialSource = 'stored' | 'env' | 'none'
@@ -193,6 +214,111 @@ function getGithubProviderSummary(
return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}` return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}`
} }
function findCodexOAuthProfile(
profiles: ProviderProfile[],
profileId?: string,
): ProviderProfile | undefined {
if (!profileId) {
return undefined
}
return profiles.find(profile => profile.id === profileId)
}
function isCodexOAuthProfile(
profile: ProviderProfile | null | undefined,
profileId?: string,
): boolean {
return Boolean(profile && profileId && profile.id === profileId)
}
function CodexOAuthSetup({
onBack,
onConfigured,
}: {
onBack: () => void
onConfigured: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise<void>
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => {
await onConfigured(tokens, persistCredentials)
}, [onConfigured])
useKeybinding('confirm:no', onBack, [onBack])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
})
if (status.state === 'error') {
return (
<Box flexDirection="column" gap={1}>
<Text color="error" bold>
Codex OAuth failed
</Text>
<Text>{status.message}</Text>
<Text dimColor>Press Enter or Esc to go back.</Text>
<Select
options={[
{
value: 'back',
label: 'Back',
description: 'Return to provider presets',
},
]}
onChange={onBack}
onCancel={onBack}
visibleOptionCount={1}
/>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Codex OAuth
</Text>
<Text>
Sign in with your ChatGPT account in the browser. OpenClaude will store
the resulting Codex credentials securely and switch this session to the
new Codex login when setup completes.
</Text>
{status.state === 'starting' ? (
<Text dimColor>Starting local callback and preparing your browser...</Text>
) : status.browserOpened === false ? (
<>
<Text color="warning">
Browser did not open automatically. Visit this URL to continue:
</Text>
<Text>{status.authUrl}</Text>
</>
) : status.browserOpened === true ? (
<>
<Text dimColor>
Browser opened. Finish the ChatGPT sign-in there and this setup will
complete automatically.
</Text>
<Text>{status.authUrl}</Text>
</>
) : (
<Text dimColor>Opening your browser...</Text>
)}
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
)
}
export function ProviderManager({ mode, onDone }: Props): React.ReactNode { export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv() const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB) const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
@@ -212,6 +338,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] = const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] =
React.useState(() => initialHasGithubCredential || initialIsGithubActive) React.useState(() => initialHasGithubCredential || initialIsGithubActive)
const githubRefreshEpochRef = React.useRef(0) const githubRefreshEpochRef = React.useRef(0)
const codexRefreshEpochRef = React.useRef(0)
const [screen, setScreen] = React.useState<Screen>( const [screen, setScreen] = React.useState<Screen>(
mode === 'first-run' ? 'select-preset' : 'menu', mode === 'first-run' ? 'select-preset' : 'menu',
) )
@@ -226,6 +353,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const [cursorOffset, setCursorOffset] = React.useState(0) const [cursorOffset, setCursorOffset] = React.useState(0)
const [statusMessage, setStatusMessage] = React.useState<string | undefined>() const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
const [errorMessage, setErrorMessage] = React.useState<string | undefined>() const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] =
React.useState(false)
const [storedCodexOAuthProfileId, setStoredCodexOAuthProfileId] =
React.useState<string | undefined>()
const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({ const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
state: 'idle', state: 'idle',
}) })
@@ -263,19 +394,102 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
})() })()
}, []) }, [])
const refreshCodexOAuthCredentialState = React.useCallback((): void => {
if (isBareMode()) {
codexRefreshEpochRef.current += 1
setHasStoredCodexOAuthCredentials(false)
setStoredCodexOAuthProfileId(undefined)
return
}
const refreshEpoch = ++codexRefreshEpochRef.current
void (async () => {
const credentials = await readCodexCredentialsAsync()
if (refreshEpoch !== codexRefreshEpochRef.current) {
return
}
setHasStoredCodexOAuthCredentials(
Boolean(
credentials?.apiKey ||
credentials?.accessToken ||
credentials?.refreshToken ||
credentials?.idToken,
),
)
setStoredCodexOAuthProfileId(credentials?.profileId)
})()
}, [])
React.useEffect(() => { React.useEffect(() => {
refreshGithubProviderState() refreshGithubProviderState()
refreshCodexOAuthCredentialState()
return () => { return () => {
githubRefreshEpochRef.current += 1 githubRefreshEpochRef.current += 1
codexRefreshEpochRef.current += 1
} }
}, [refreshGithubProviderState]) }, [refreshCodexOAuthCredentialState, refreshGithubProviderState])
React.useEffect(() => {
if (screen !== 'select-ollama-model') {
return
}
let cancelled = false
setOllamaSelection({ state: 'loading' })
void (async () => {
const available = await hasLocalOllama(draft.baseUrl)
if (!available) {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message:
'Could not reach Ollama. Start Ollama first, or enter the endpoint manually.',
})
}
return
}
const models = await listOllamaModels(draft.baseUrl)
if (models.length === 0) {
if (!cancelled) {
setOllamaSelection({
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, or enter details manually.',
})
}
return
}
const ranked = rankOllamaModels(models, 'balanced')
const recommended = recommendOllamaModel(models, 'balanced')
if (!cancelled) {
setOllamaSelection({
state: 'ready',
defaultValue: recommended?.name ?? ranked[0]?.name,
options: ranked.map(model => ({
label: model.name,
value: model.name,
description: model.summary,
})),
})
}
})()
return () => {
cancelled = true
}
}, [draft.baseUrl, screen])
function refreshProfiles(): void { function refreshProfiles(): void {
const nextProfiles = getProviderProfiles() const nextProfiles = getProviderProfiles()
setProfiles(nextProfiles) setProfiles(nextProfiles)
setActiveProfileId(getActiveProviderProfile()?.id) setActiveProfileId(getActiveProviderProfile()?.id)
refreshGithubProviderState() refreshGithubProviderState()
refreshCodexOAuthCredentialState()
} }
function clearStartupProviderOverrideFromUserSettings(): string | null { function clearStartupProviderOverrideFromUserSettings(): string | null {
@@ -292,6 +506,123 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return error ? error.message : null return error ? error.message : null
} }
function buildCodexOAuthActivationMessage(options: {
prefix: string
activationWarning: string | null
warnings: string[]
}): string {
if (options.activationWarning) {
return `${options.prefix}. Saved for next startup. Warning: ${options.warnings.join('; ')}.`
}
if (options.warnings.length > 0) {
return `${options.prefix}. OpenClaude switched to it for this session with warnings: ${options.warnings.join('; ')}.`
}
return `${options.prefix}. OpenClaude switched to it for this session.`
}
async function activateCodexOAuthSession(tokens?: {
accessToken: string
refreshToken?: string
accountId?: string
idToken?: string
}): Promise<string | null> {
const oauthEnv = buildCodexOAuthProfileEnv({
accessToken: tokens?.accessToken ?? '',
accountId: tokens?.accountId,
idToken: tokens?.idToken,
})
if (oauthEnv) {
return applySavedProfileToCurrentSession({
profileFile: createProfileFile('codex', oauthEnv),
})
}
const storedCredentials = await readCodexCredentialsAsync()
if (!storedCredentials) {
return 'stored Codex OAuth credentials could not be loaded'
}
const storedEnv = buildCodexOAuthProfileEnv({
accessToken: storedCredentials.accessToken,
accountId: storedCredentials.accountId,
idToken: storedCredentials.idToken,
})
if (!storedEnv) {
return 'stored Codex OAuth credentials are missing a ChatGPT account id'
}
return applySavedProfileToCurrentSession({
profileFile: createProfileFile('codex', storedEnv),
})
}
async function activateSelectedProvider(profileId: string): Promise<void> {
let providerLabel = 'provider'
try {
if (profileId === GITHUB_PROVIDER_ID) {
providerLabel = GITHUB_PROVIDER_LABEL
const githubError = activateGithubProvider()
if (githubError) {
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
setScreen('menu')
return
}
refreshProfiles()
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
setScreen('menu')
return
}
const active = setActiveProviderProfile(profileId)
if (!active) {
setErrorMessage('Could not change active provider.')
setScreen('menu')
return
}
providerLabel = active.name
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const isActiveCodexOAuth = isCodexOAuthProfile(
active,
storedCodexOAuthProfileId,
)
const activationWarning = isActiveCodexOAuth
? await activateCodexOAuthSession()
: null
refreshProfiles()
setStatusMessage(
isActiveCodexOAuth
? buildCodexOAuthActivationMessage({
prefix: `Active provider: ${active.name}`,
activationWarning,
warnings: [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning)),
})
: settingsOverrideError
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
: `Active provider: ${active.name}`,
)
setScreen('menu')
} catch (error) {
refreshProfiles()
setStatusMessage(undefined)
const detail = error instanceof Error ? error.message : String(error)
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
setScreen('menu')
}
}
function closeWithCancelled(message: string): void { function closeWithCancelled(message: string): void {
onDone({ action: 'cancelled', message }) onDone({ action: 'cancelled', message })
} }
@@ -383,59 +714,6 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return null return null
} }
React.useEffect(() => {
if (screen !== 'select-ollama-model') {
return
}
let cancelled = false
setOllamaSelection({ state: 'loading' })
void (async () => {
const available = await hasLocalOllama(draft.baseUrl)
if (!available) {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message:
'Could not reach Ollama. Start Ollama first, or enter the endpoint manually.',
})
}
return
}
const models = await listOllamaModels(draft.baseUrl)
if (models.length === 0) {
if (!cancelled) {
setOllamaSelection({
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, or enter details manually.',
})
}
return
}
const ranked = rankOllamaModels(models, 'balanced')
const recommended = recommendOllamaModel(models, 'balanced')
if (!cancelled) {
setOllamaSelection({
state: 'ready',
defaultValue: recommended?.name ?? ranked[0]?.name,
options: ranked.map(model => ({
label: model.name,
value: model.name,
description: model.summary,
})),
})
}
})()
return () => {
cancelled = true
}
}, [draft.baseUrl, screen])
function startCreateFromPreset(preset: ProviderPreset): void { function startCreateFromPreset(preset: ProviderPreset): void {
const defaults = getProviderPresetDefaults(preset) const defaults = getProviderPresetDefaults(preset)
const nextDraft = { const nextDraft = {
@@ -557,7 +835,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
description: 'Choose another provider preset', description: 'Choose another provider preset',
}, },
]} ]}
onChange={value => { onChange={(value: string) => {
if (value === 'manual') { if (value === 'manual') {
setFormStepIndex(0) setFormStepIndex(0)
setCursorOffset(draft.name.length) setCursorOffset(draft.name.length)
@@ -588,7 +866,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
defaultFocusValue={ollamaSelection.defaultValue} defaultFocusValue={ollamaSelection.defaultValue}
inlineDescriptions inlineDescriptions
visibleOptionCount={Math.min(8, ollamaSelection.options.length)} visibleOptionCount={Math.min(8, ollamaSelection.options.length)}
onChange={value => { onChange={(value: string) => {
const nextDraft = { const nextDraft = {
...draft, ...draft,
model: value, model: value,
@@ -654,6 +932,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
}) })
function renderPresetSelection(): React.ReactNode { function renderPresetSelection(): React.ReactNode {
const canUseCodexOAuth = !isBareMode()
const options = [ const options = [
{ {
value: 'anthropic', value: 'anthropic',
@@ -670,6 +949,16 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
label: 'OpenAI', label: 'OpenAI',
description: 'OpenAI API with API key', description: 'OpenAI API with API key',
}, },
...(canUseCodexOAuth
? [
{
value: 'codex-oauth',
label: 'Codex OAuth',
description:
'Sign in with ChatGPT in your browser and store Codex credentials securely',
},
]
: []),
{ {
value: 'moonshotai', value: 'moonshotai',
label: 'Moonshot AI', label: 'Moonshot AI',
@@ -741,11 +1030,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
</Text> </Text>
<Select <Select
options={options} options={options}
onChange={value => { onChange={(value: string) => {
if (value === 'skip') { if (value === 'skip') {
closeWithCancelled('Provider setup skipped') closeWithCancelled('Provider setup skipped')
return return
} }
if (value === 'codex-oauth') {
setScreen('codex-oauth')
return
}
startCreateFromPreset(value as ProviderPreset) startCreateFromPreset(value as ProviderPreset)
}} }}
onCancel={() => { onCancel={() => {
@@ -755,7 +1048,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
} }
setScreen('menu') setScreen('menu')
}} }}
visibleOptionCount={Math.min(12, options.length)} visibleOptionCount={Math.min(13, options.length)}
/> />
</Box> </Box>
) )
@@ -832,6 +1125,15 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
description: 'Remove a provider profile', description: 'Remove a provider profile',
disabled: !hasSelectableProviders, disabled: !hasSelectableProviders,
}, },
...(hasStoredCodexOAuthCredentials
? [
{
value: 'logout-codex-oauth',
label: 'Log out Codex OAuth',
description: 'Clear securely stored Codex OAuth credentials',
},
]
: []),
{ {
value: 'done', value: 'done',
label: 'Done', label: 'Done',
@@ -876,7 +1178,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
</Box> </Box>
<Select <Select
options={options} options={options}
onChange={value => { onChange={(value: string) => {
setErrorMessage(undefined) setErrorMessage(undefined)
switch (value) { switch (value) {
case 'add': case 'add':
@@ -897,6 +1199,47 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setScreen('select-delete') setScreen('select-delete')
} }
break break
case 'logout-codex-oauth': {
const cleared = clearCodexCredentials()
if (!cleared.success) {
setErrorMessage(
cleared.warning ??
'Could not clear Codex OAuth credentials.',
)
break
}
setHasStoredCodexOAuthCredentials(false)
setStoredCodexOAuthProfileId(undefined)
const codexProfile = findCodexOAuthProfile(
getProviderProfiles(),
storedCodexOAuthProfileId,
)
let settingsOverrideError: string | null = null
if (codexProfile) {
const result = deleteProviderProfile(codexProfile.id)
if (!result.removed) {
setErrorMessage(
'Codex OAuth credentials were cleared, but the Codex profile could not be removed.',
)
refreshProfiles()
break
}
clearPersistedCodexOAuthProfile()
settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings()
: null
}
refreshProfiles()
setStatusMessage(
settingsOverrideError
? `Codex OAuth logged out. Warning: could not clear startup provider override (${settingsOverrideError}).`
: 'Codex OAuth logged out.',
)
break
}
default: default:
closeWithCancelled('Provider manager closed') closeWithCancelled('Provider manager closed')
break break
@@ -982,6 +1325,82 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
case 'select-ollama-model': case 'select-ollama-model':
content = renderOllamaSelection() content = renderOllamaSelection()
break break
case 'codex-oauth':
content = (
<CodexOAuthSetup
onBack={() => setScreen('select-preset')}
onConfigured={async (tokens, persistCredentials) => {
const payload: ProviderProfileInput = {
provider: 'openai',
name: CODEX_OAUTH_PROVIDER_NAME,
baseUrl: DEFAULT_CODEX_BASE_URL,
model: CODEX_OAUTH_PROVIDER_MODEL,
apiKey: '',
}
const existing = findCodexOAuthProfile(
getProviderProfiles(),
storedCodexOAuthProfileId,
)
const saved = existing
? updateProviderProfile(existing.id, payload)
: addProviderProfile(payload, { makeActive: true })
if (!saved) {
setErrorMessage(
'Codex OAuth login finished, but the provider profile could not be saved.',
)
setScreen('menu')
return
}
const active =
existing && activeProfileId !== saved.id
? setActiveProviderProfile(saved.id)
: saved
if (!active) {
setErrorMessage(
'Codex OAuth login finished, but the provider could not be set as the startup provider.',
)
setScreen('menu')
return
}
persistCredentials({ profileId: saved.id })
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const activationWarning = await activateCodexOAuthSession(tokens)
setHasStoredCodexOAuthCredentials(true)
setStoredCodexOAuthProfileId(saved.id)
refreshProfiles()
const warnings = [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning))
const message = buildCodexOAuthActivationMessage({
prefix: 'Codex OAuth configured',
activationWarning,
warnings,
})
if (mode === 'first-run') {
onDone({
action: 'saved',
activeProfileId: active.id,
message,
})
return
}
setStatusMessage(message)
setErrorMessage(undefined)
setScreen('menu')
}}
/>
)
break
case 'form': case 'form':
content = renderForm() content = renderForm()
break break
@@ -990,34 +1409,7 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
'Set active provider', 'Set active provider',
'No providers available. Add one first.', 'No providers available. Add one first.',
profileId => { profileId => {
if (profileId === GITHUB_PROVIDER_ID) { void activateSelectedProvider(profileId)
const githubError = activateGithubProvider()
if (githubError) {
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
setScreen('menu')
return
}
refreshProfiles()
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
setScreen('menu')
return
}
const active = setActiveProviderProfile(profileId)
if (!active) {
setErrorMessage('Could not change active provider.')
setScreen('menu')
return
}
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
refreshProfiles()
setStatusMessage(
settingsOverrideError
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
: `Active provider: ${active.name}`,
)
setScreen('menu')
}, },
{ includeGithub: true }, { includeGithub: true },
) )
@@ -1048,10 +1440,27 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return return
} }
const deletedCodexOAuthProfile =
findCodexOAuthProfile(
profiles,
storedCodexOAuthProfileId,
)?.id === profileId
const result = deleteProviderProfile(profileId) const result = deleteProviderProfile(profileId)
if (!result.removed) { if (!result.removed) {
setErrorMessage('Could not delete provider.') setErrorMessage('Could not delete provider.')
} else { } else {
if (deletedCodexOAuthProfile) {
const cleared = clearCodexCredentials()
if (!cleared.success) {
setErrorMessage(
cleared.warning ??
'Provider deleted, but Codex OAuth credentials could not be cleared.',
)
} else {
setStoredCodexOAuthProfileId(undefined)
}
clearPersistedCodexOAuthProfile()
}
const settingsOverrideError = result.activeProfileId const settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings() ? clearStartupProviderOverrideFromUserSettings()
: null : null

View File

@@ -5,7 +5,7 @@
* Addresses: https://github.com/Gitlawb/openclaude/issues/55 * Addresses: https://github.com/Gitlawb/openclaude/issues/55
*/ */
import { isLocalProviderUrl } from '../services/api/providerConfig.js' import { isLocalProviderUrl, resolveProviderRequest } from '../services/api/providerConfig.js'
import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js' import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js'
import { getSettings_DEPRECATED } from '../utils/settings/settings.js' import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
import { parseUserSpecifiedModel } from '../utils/model/model.js' import { parseUserSpecifiedModel } from '../utils/model/model.js'
@@ -110,10 +110,17 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
if (useOpenAI) { if (useOpenAI) {
const rawModel = process.env.OPENAI_MODEL || 'gpt-4o' const rawModel = process.env.OPENAI_MODEL || 'gpt-4o'
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' const resolvedRequest = resolveProviderRequest({
model: rawModel,
baseUrl: process.env.OPENAI_BASE_URL,
})
const baseUrl = resolvedRequest.baseUrl
const isLocal = isLocalProviderUrl(baseUrl) const isLocal = isLocalProviderUrl(baseUrl)
let name = 'OpenAI' let name = 'OpenAI'
if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek' // Override to Codex when resolved endpoint is Codex
if (resolvedRequest.transport === 'codex_responses' || baseUrl.includes('chatgpt.com/backend-api/codex')) {
name = 'Codex'
} else if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel)) name = 'DeepSeek'
else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter' else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter'
else if (/together/i.test(baseUrl)) name = 'Together AI' else if (/together/i.test(baseUrl)) name = 'Together AI'
else if (/groq/i.test(baseUrl)) name = 'Groq' else if (/groq/i.test(baseUrl)) name = 'Groq'
@@ -123,26 +130,9 @@ function detectProvider(): { name: string; model: string; baseUrl: string; isLoc
else if (isLocal) name = getLocalOpenAICompatibleProviderLabel(baseUrl) else if (isLocal) name = getLocalOpenAICompatibleProviderLabel(baseUrl)
// Resolve model alias to actual model name + reasoning effort // Resolve model alias to actual model name + reasoning effort
let displayModel = rawModel let displayModel = resolvedRequest.resolvedModel
const codexAliases: Record<string, { model: string; reasoningEffort?: string }> = { if (resolvedRequest.reasoning?.effort) {
codexplan: { model: 'gpt-5.4', reasoningEffort: 'high' }, displayModel = `${displayModel} (${resolvedRequest.reasoning.effort})`
'gpt-5.4': { model: 'gpt-5.4', reasoningEffort: 'high' },
'gpt-5.3-codex': { model: 'gpt-5.3-codex', reasoningEffort: 'high' },
'gpt-5.3-codex-spark': { model: 'gpt-5.3-codex-spark' },
codexspark: { model: 'gpt-5.3-codex-spark' },
'gpt-5.2-codex': { model: 'gpt-5.2-codex', reasoningEffort: 'high' },
'gpt-5.1-codex-max': { model: 'gpt-5.1-codex-max', reasoningEffort: 'high' },
'gpt-5.1-codex-mini': { model: 'gpt-5.1-codex-mini' },
'gpt-5.4-mini': { model: 'gpt-5.4-mini', reasoningEffort: 'medium' },
'gpt-5.2': { model: 'gpt-5.2', reasoningEffort: 'medium' },
}
const alias = rawModel.toLowerCase()
if (alias in codexAliases) {
const resolved = codexAliases[alias]
displayModel = resolved.model
if (resolved.reasoningEffort) {
displayModel = `${displayModel} (${resolved.reasoningEffort})`
}
} }
return { name, model: displayModel, baseUrl, isLocal } return { name, model: displayModel, baseUrl, isLocal }

View File

@@ -0,0 +1,173 @@
import React from 'react'
import { getOriginalCwd } from '../../../bootstrap/state.js'
import { Box, Text } from '../../../ink.js'
import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'
import { env } from '../../../utils/env.js'
import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'
import { usePermissionRequestLogging } from '../hooks.js'
import { PermissionDialog } from '../PermissionDialog.js'
import {
PermissionPrompt,
type PermissionPromptOption,
} from '../PermissionPrompt.js'
import type { PermissionRequestProps } from '../PermissionRequest.js'
import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'
import { logUnaryPermissionEvent } from '../utils.js'
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no'
export function MonitorPermissionRequest({
toolUseConfirm,
onDone,
onReject,
workerBadge,
}: PermissionRequestProps) {
const { command, description } = toolUseConfirm.input as {
command?: string
description?: string
}
usePermissionRequestLogging(toolUseConfirm, {
completion_type: 'tool_use_single',
language_name: 'none',
})
const handleSelect = (
value: OptionValue,
feedback?: string,
) => {
switch (value) {
case 'yes': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback)
onDone()
break
}
case 'yes-dont-ask-again': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'accept',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
// Save the rule under 'Bash' toolName because checkPermissions
// delegates to bashToolHasPermission which matches rules against
// BashTool. Using 'Monitor' here would create a rule that's never
// checked. Command-specific prefix (like BashTool's shellRuleMatching).
const cmdForRule = command?.trim() || ''
const prefix = cmdForRule.split(/\s+/).slice(0, 2).join(' ')
toolUseConfirm.onAllow(toolUseConfirm.input, prefix ? [
{
type: 'addRules',
rules: [{ toolName: 'Bash', ruleContent: `${prefix}:*` }],
behavior: 'allow',
destination: 'localSettings',
},
] : [])
onDone()
break
}
case 'no': {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onReject(feedback)
onReject()
onDone()
break
}
}
}
const handleCancel = () => {
logUnaryPermissionEvent({
completion_type: 'tool_use_single',
event: 'reject',
metadata: {
language_name: 'none',
message_id: toolUseConfirm.assistantMessage.message.id,
platform: env.platform,
},
})
toolUseConfirm.onReject()
onReject()
onDone()
}
const showAlwaysAllow = shouldShowAlwaysAllowOptions()
const originalCwd = getOriginalCwd()
const options: PermissionPromptOption<OptionValue>[] = [
{
label: 'Yes',
value: 'yes',
feedbackConfig: { type: 'accept' },
},
]
if (showAlwaysAllow) {
options.push({
label: (
<Text>
Yes, and don&apos;t ask again for{' '}
<Text bold>Monitor</Text> commands in{' '}
<Text bold>{originalCwd}</Text>
</Text>
),
value: 'yes-dont-ask-again',
})
}
options.push({
label: 'No',
value: 'no',
feedbackConfig: { type: 'reject' },
})
const toolAnalyticsContext = {
toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name),
isMcp: toolUseConfirm.tool.isMcp ?? false,
}
return (
<PermissionDialog title="Monitor" workerBadge={workerBadge}>
<Box flexDirection="column" paddingX={2} paddingY={1}>
<Text>
Monitor({command ?? ''})
</Text>
{description ? (
<Text dimColor>{description}</Text>
) : null}
</Box>
<Box flexDirection="column">
<PermissionRuleExplanation
permissionResult={toolUseConfirm.permissionResult}
toolType="tool"
/>
<PermissionPrompt
options={options}
onSelect={handleSelect}
onCancel={handleCancel}
toolAnalyticsContext={toolAnalyticsContext}
/>
</Box>
</PermissionDialog>
)
}

View File

@@ -0,0 +1,220 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import { createRoot, Text } from '../ink.js'
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
getOutput: () => string
} {
let output = ''
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
stdout.on('data', chunk => {
output += chunk.toString()
})
return {
stdout,
stdin,
getOutput: () => output,
}
}
async function waitForCondition(
predicate: () => boolean,
options?: { timeoutMs?: number; intervalMs?: number },
): Promise<void> {
const timeoutMs = options?.timeoutMs ?? 5000
const intervalMs = options?.intervalMs ?? 10
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(intervalMs)
}
throw new Error('Timed out waiting for useCodexOAuthFlow test condition')
}
function extractLastFrame(output: string): string {
let lastFrame: string | null = null
let cursor = 0
while (cursor < output.length) {
const start = output.indexOf(SYNC_START, cursor)
if (start === -1) break
const contentStart = start + SYNC_START.length
const end = output.indexOf(SYNC_END, contentStart)
if (end === -1) break
const frame = output.slice(contentStart, end)
if (frame.trim().length > 0) {
lastFrame = frame
}
cursor = end + SYNC_END.length
}
return lastFrame ?? output
}
const TOKENS = {
accessToken: 'oauth-access-token',
refreshToken: 'oauth-refresh-token',
accountId: 'acct_oauth',
idToken: 'oauth-id-token',
apiKey: 'oauth-api-key',
}
afterEach(() => {
mock.restore()
})
test('does not persist credentials when downstream setup rejects', async () => {
const saveCodexCredentials = mock(() => ({ success: true }))
const cleanup = mock(() => {})
const onAuthenticated = mock(async () => {
throw new Error('profile save failed')
})
const deps = {
createOAuthService: () => ({
async startOAuthFlow(
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
) {
await onAuthorizationUrl('https://chatgpt.com/codex')
return TOKENS
},
cleanup,
}),
openBrowser: async () => true,
saveCodexCredentials,
isBareMode: () => false,
}
const { useCodexOAuthFlow } = await import(
`./useCodexOAuthFlow.js?real-reject-${Date.now()}-${Math.random()}`
)
function Harness(): React.ReactNode {
const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
deps,
})
return <Text>{status.state === 'error' ? status.message : status.state}</Text>
}
const streams = createTestStreams()
const root = await createRoot({
stdout: streams.stdout as unknown as NodeJS.WriteStream,
stdin: streams.stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
try {
await waitForCondition(() => onAuthenticated.mock.calls.length === 1)
await Bun.sleep(0)
await Bun.sleep(0)
expect(onAuthenticated).toHaveBeenCalled()
expect(saveCodexCredentials).not.toHaveBeenCalled()
} finally {
root.unmount()
streams.stdin.end()
streams.stdout.end()
await Bun.sleep(0)
}
})
test('persists credentials with profile linkage after downstream setup succeeds', async () => {
const saveCodexCredentials = mock(() => ({ success: true }))
const onAuthenticated = mock(
async (
_tokens: typeof TOKENS,
persistCredentials: (options?: { profileId?: string }) => void,
) => {
persistCredentials({ profileId: 'profile_codex_oauth' })
},
)
const cleanup = mock(() => {})
const deps = {
createOAuthService: () => ({
async startOAuthFlow(
onAuthorizationUrl: (authUrl: string) => void | Promise<void>,
) {
await onAuthorizationUrl('https://chatgpt.com/codex')
return TOKENS
},
cleanup,
}),
openBrowser: async () => true,
saveCodexCredentials,
isBareMode: () => false,
}
const { useCodexOAuthFlow } = await import(
`./useCodexOAuthFlow.js?real-persist-${Date.now()}-${Math.random()}`
)
function Harness(): React.ReactNode {
const handleAuthenticated = React.useCallback(onAuthenticated, [onAuthenticated])
useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
deps,
})
return <Text>waiting</Text>
}
const streams = createTestStreams()
const root = await createRoot({
stdout: streams.stdout as unknown as NodeJS.WriteStream,
stdin: streams.stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
try {
await waitForCondition(() => onAuthenticated.mock.calls.length === 1)
await waitForCondition(() => saveCodexCredentials.mock.calls.length === 1)
expect(onAuthenticated).toHaveBeenCalled()
expect(saveCodexCredentials).toHaveBeenCalledWith({
apiKey: TOKENS.apiKey,
accessToken: TOKENS.accessToken,
refreshToken: TOKENS.refreshToken,
idToken: TOKENS.idToken,
accountId: TOKENS.accountId,
profileId: 'profile_codex_oauth',
})
} finally {
root.unmount()
streams.stdin.end()
streams.stdout.end()
await Bun.sleep(0)
}
})

View File

@@ -0,0 +1,134 @@
import * as React from 'react'
import {
CodexOAuthService,
type CodexOAuthTokens,
} from '../services/api/codexOAuth.js'
import { openBrowser } from '../utils/browser.js'
import { saveCodexCredentials } from '../utils/codexCredentials.js'
import { isBareMode } from '../utils/envUtils.js'
export type CodexOAuthFlowStatus =
| { state: 'starting' }
| {
state: 'waiting'
authUrl: string
browserOpened: boolean | null
}
| {
state: 'error'
message: string
}
type PersistCodexOAuthCredentials = (options?: {
profileId?: string
}) => void
type CodexOAuthFlowDependencies = {
createOAuthService?: () => Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup'
>
openBrowser?: typeof openBrowser
saveCodexCredentials?: typeof saveCodexCredentials
isBareMode?: typeof isBareMode
}
function createDefaultOAuthService(): Pick<
CodexOAuthService,
'startOAuthFlow' | 'cleanup'
> {
return new CodexOAuthService()
}
export function useCodexOAuthFlow(options: {
onAuthenticated: (
tokens: CodexOAuthTokens,
persistCredentials: PersistCodexOAuthCredentials,
) => void | Promise<void>
deps?: CodexOAuthFlowDependencies
}): CodexOAuthFlowStatus {
const { onAuthenticated } = options
const createOAuthService =
options.deps?.createOAuthService ?? createDefaultOAuthService
const openBrowserFn = options.deps?.openBrowser ?? openBrowser
const saveCredentials =
options.deps?.saveCodexCredentials ?? saveCodexCredentials
const isBareModeFn = options.deps?.isBareMode ?? isBareMode
const [status, setStatus] = React.useState<CodexOAuthFlowStatus>({
state: 'starting',
})
React.useEffect(() => {
if (isBareModeFn()) {
setStatus({
state: 'error',
message:
'Codex OAuth is unavailable in --bare because secure storage is disabled.',
})
return
}
let cancelled = false
const oauthService = createOAuthService()
void oauthService
.startOAuthFlow(async authUrl => {
if (cancelled) return
setStatus({
state: 'waiting',
authUrl,
browserOpened: null,
})
const browserOpened = await openBrowserFn(authUrl)
if (cancelled) return
setStatus({
state: 'waiting',
authUrl,
browserOpened,
})
})
.then(async tokens => {
if (cancelled) return
const persistCredentials: PersistCodexOAuthCredentials = options => {
const saved = saveCredentials({
apiKey: tokens.apiKey,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
idToken: tokens.idToken,
accountId: tokens.accountId,
profileId: options?.profileId,
})
if (!saved.success) {
throw new Error(
saved.warning ??
'Codex OAuth succeeded, but credentials could not be saved securely.',
)
}
}
await onAuthenticated(tokens, persistCredentials)
})
.catch(error => {
if (cancelled) return
setStatus({
state: 'error',
message: error instanceof Error ? error.message : String(error),
})
})
return () => {
cancelled = true
oauthService.cleanup()
}
}, [
createOAuthService,
isBareModeFn,
onAuthenticated,
openBrowserFn,
saveCredentials,
])
return status
}

View File

@@ -37,8 +37,6 @@ export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
TASK_OUTPUT_TOOL_NAME, TASK_OUTPUT_TOOL_NAME,
EXIT_PLAN_MODE_V2_TOOL_NAME, EXIT_PLAN_MODE_V2_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME,
// Allow Agent tool for agents when user is ant (enables nested agents)
...(process.env.USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]),
ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME,
TASK_STOP_TOOL_NAME, TASK_STOP_TOOL_NAME,
// Prevent recursive workflow execution inside subagents. // Prevent recursive workflow execution inside subagents.
@@ -82,9 +80,9 @@ export const IN_PROCESS_TEAMMATE_ALLOWED_TOOLS = new Set([
SEND_MESSAGE_TOOL_NAME, SEND_MESSAGE_TOOL_NAME,
// Teammate-created crons are tagged with the creating agentId and routed to // Teammate-created crons are tagged with the creating agentId and routed to
// that teammate's pendingUserMessages queue (see useScheduledTasks.ts). // that teammate's pendingUserMessages queue (see useScheduledTasks.ts).
...(feature('AGENT_TRIGGERS') CRON_CREATE_TOOL_NAME,
? [CRON_CREATE_TOOL_NAME, CRON_DELETE_TOOL_NAME, CRON_LIST_TOOL_NAME] CRON_DELETE_TOOL_NAME,
: []), CRON_LIST_TOOL_NAME,
]) ])
/* /*

View File

@@ -0,0 +1,64 @@
import { afterEach, describe, expect, test } from 'bun:test'
afterEach(() => {
delete process.env.REPO_MAP
})
describe('getRepoMapContext', () => {
test('returns null when REPO_MAP env flag is off (default)', async () => {
const { getRepoMapContext } = await import('./context.js')
const result = await getRepoMapContext()
expect(result).toBeNull()
})
test('buildRepoMap produces valid output for context injection', async () => {
process.env.REPO_MAP = '1'
const { mkdtempSync, writeFileSync, rmSync } = await import('fs')
const { tmpdir } = await import('os')
const { join } = await import('path')
const { buildRepoMap } = await import('./context/repoMap/index.js')
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-ctx-'))
try {
writeFileSync(
join(tempDir, 'main.ts'),
'export function main(): void { console.log("hello") }\n',
)
writeFileSync(
join(tempDir, 'utils.ts'),
'import { main } from "./main"\nexport function helper(): void { main() }\n',
)
const result = await buildRepoMap({
root: tempDir,
maxTokens: 1024,
})
// Valid map that could be injected
expect(result.map.length).toBeGreaterThan(0)
expect(result.tokenCount).toBeGreaterThan(0)
expect(result.tokenCount).toBeLessThanOrEqual(1024)
expect(typeof result.cacheHit).toBe('boolean')
} finally {
rmSync(tempDir, { recursive: true, force: true })
const { invalidateCache } = await import('./context/repoMap/index.js')
invalidateCache(tempDir)
}
})
test('getSystemContext does not include repoMap key when flag is off', async () => {
const { getSystemContext } = await import('./context.js')
const result = await getSystemContext()
expect('repoMap' in result).toBe(false)
})
test('getSystemContext includes repoMap key when REPO_MAP env flag is on', async () => {
process.env.REPO_MAP = '1'
const { getSystemContext, getRepoMapContext } = await import('./context.js')
getRepoMapContext.cache.clear?.()
getSystemContext.cache.clear?.()
const result = await getSystemContext()
expect(typeof result.repoMap).toBe('string')
expect(result.repoMap!.length).toBeGreaterThan(0)
})
})

View File

@@ -31,6 +31,7 @@ export function setSystemPromptInjection(value: string | null): void {
// Clear context caches immediately when injection changes // Clear context caches immediately when injection changes
getUserContext.cache.clear?.() getUserContext.cache.clear?.()
getSystemContext.cache.clear?.() getSystemContext.cache.clear?.()
getRepoMapContext.cache.clear?.()
} }
export const getGitStatus = memoize(async (): Promise<string | null> => { export const getGitStatus = memoize(async (): Promise<string | null> => {
@@ -110,6 +111,35 @@ export const getGitStatus = memoize(async (): Promise<string | null> => {
} }
}) })
export const getRepoMapContext = memoize(
async (): Promise<string | null> => {
const runtimeEnabled = isEnvTruthy(process.env.REPO_MAP)
if (!runtimeEnabled) return null
if (isBareMode()) return null
if (isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) return null
try {
const startTime = Date.now()
logForDiagnosticsNoPII('info', 'repo_map_started')
const { buildRepoMap } = await import('./context/repoMap/index.js')
const result = await buildRepoMap({ maxTokens: 1024 })
logForDiagnosticsNoPII('info', 'repo_map_completed', {
duration_ms: Date.now() - startTime,
token_count: result.tokenCount,
file_count: result.fileCount,
cache_hit: result.cacheHit,
})
if (!result.map || result.map.length === 0) return null
return `This is a structural map of the repository, ranked by importance. Use it to understand the codebase architecture.\n\n${result.map}`
} catch (err) {
logForDiagnosticsNoPII('warn', 'repo_map_failed', {
error: String(err),
})
return null
}
},
)
/** /**
* This context is prepended to each conversation, and cached for the duration of the conversation. * This context is prepended to each conversation, and cached for the duration of the conversation.
*/ */
@@ -127,6 +157,8 @@ export const getSystemContext = memoize(
? null ? null
: await getGitStatus() : await getGitStatus()
const repoMap = await getRepoMapContext()
// Include system prompt injection if set (for cache breaking, internal-only) // Include system prompt injection if set (for cache breaking, internal-only)
const injection = feature('BREAK_CACHE_COMMAND') const injection = feature('BREAK_CACHE_COMMAND')
? getSystemPromptInjection() ? getSystemPromptInjection()
@@ -135,11 +167,13 @@ export const getSystemContext = memoize(
logForDiagnosticsNoPII('info', 'system_context_completed', { logForDiagnosticsNoPII('info', 'system_context_completed', {
duration_ms: Date.now() - startTime, duration_ms: Date.now() - startTime,
has_git_status: gitStatus !== null, has_git_status: gitStatus !== null,
has_repo_map: repoMap !== null,
has_injection: injection !== null, has_injection: injection !== null,
}) })
return { return {
...(gitStatus && { gitStatus }), ...(gitStatus && { gitStatus }),
...(repoMap && { repoMap }),
...(feature('BREAK_CACHE_COMMAND') && injection ...(feature('BREAK_CACHE_COMMAND') && injection
? { ? {
cacheBreaker: `[CACHE_BREAKER: ${injection}]`, cacheBreaker: `[CACHE_BREAKER: ${injection}]`,

View File

@@ -0,0 +1,29 @@
// fileA — imports from fileB and fileC
import { CacheLayer, buildCache } from './fileB'
import { createStore, type StoreConfig } from './fileC'
export class AppController {
private cache: CacheLayer
private config: StoreConfig
constructor(config: StoreConfig) {
this.cache = buildCache()
this.config = config
}
initialize(): void {
const store = createStore()
this.cache.cacheSet('primary', store)
}
getFromCache(key: string): unknown {
return this.cache.cacheGet(key)
}
}
export function startApp(config: StoreConfig): AppController {
const app = new AppController(config)
app.initialize()
return app
}

View File

@@ -0,0 +1,23 @@
// fileB — imports from fileC
import { DataStore, createStore } from './fileC'
export class CacheLayer {
private store: DataStore
constructor() {
this.store = createStore()
}
cacheGet(key: string): unknown | undefined {
return this.store.lookup(key)
}
cacheSet(key: string, value: unknown): void {
this.store.add(key, value)
}
}
export function buildCache(): CacheLayer {
return new CacheLayer()
}

View File

@@ -0,0 +1,22 @@
// fileC — the most imported module (imported by fileA and fileB)
export class DataStore {
private items: Map<string, unknown> = new Map()
add(key: string, value: unknown): void {
this.items.set(key, value)
}
lookup(key: string): unknown | undefined {
return this.items.get(key)
}
}
export function createStore(): DataStore {
return new DataStore()
}
export interface StoreConfig {
maxSize: number
ttl: number
}

View File

@@ -0,0 +1,9 @@
// fileD — imports from fileA
import { AppController, startApp } from './fileA'
export function runApp(): void {
const controller: AppController = startApp({ maxSize: 100, ttl: 3600 })
const result = controller.getFromCache('test')
console.log(result)
}

View File

@@ -0,0 +1,25 @@
// fileE — isolated, no imports from other fixture files
export interface Logger {
log(message: string): void
warn(message: string): void
error(message: string): void
}
export class ConsoleLogger implements Logger {
log(message: string): void {
console.log(`[LOG] ${message}`)
}
warn(message: string): void {
console.warn(`[WARN] ${message}`)
}
error(message: string): void {
console.error(`[ERROR] ${message}`)
}
}
export function createLogger(): Logger {
return new ConsoleLogger()
}

View File

@@ -0,0 +1,139 @@
import { createHash } from 'crypto'
import {
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import type { CacheData, CacheEntry, CacheStats, Tag } from './types.js'
const CACHE_VERSION = 1
const CACHE_DIR = join(homedir(), '.openclaude', 'repomap-cache')
function getCacheFilePath(root: string): string {
const hash = createHash('sha1').update(root).digest('hex')
return join(CACHE_DIR, `${hash}.json`)
}
function ensureCacheDir(): void {
if (!existsSync(CACHE_DIR)) {
mkdirSync(CACHE_DIR, { recursive: true })
}
}
/** Load cache from disk. Returns empty cache if not found or invalid. */
export function loadCache(root: string): CacheData {
const path = getCacheFilePath(root)
try {
const raw = readFileSync(path, 'utf-8')
const data = JSON.parse(raw) as CacheData
if (data.version !== CACHE_VERSION) {
return { version: CACHE_VERSION, entries: {} }
}
return data
} catch {
return { version: CACHE_VERSION, entries: {} }
}
}
/** Save cache to disk. */
export function saveCache(root: string, cache: CacheData): void {
ensureCacheDir()
const path = getCacheFilePath(root)
writeFileSync(path, JSON.stringify(cache), 'utf-8')
}
/**
* Check if a file's cached entry is still valid based on mtime and size.
* Returns the cached tags if valid, null otherwise.
*/
export function getCachedTags(
cache: CacheData,
filePath: string,
root: string,
): Tag[] | null {
const entry = cache.entries[filePath]
if (!entry) return null
try {
const absolutePath = join(root, filePath)
const stat = statSync(absolutePath)
if (stat.mtimeMs === entry.mtimeMs && stat.size === entry.size) {
return entry.tags
}
} catch {
// File may have been deleted
}
return null
}
/** Update the cache entry for a file. */
export function setCachedTags(
cache: CacheData,
filePath: string,
root: string,
tags: Tag[],
): void {
try {
const absolutePath = join(root, filePath)
const stat = statSync(absolutePath)
cache.entries[filePath] = {
tags,
mtimeMs: stat.mtimeMs,
size: stat.size,
}
} catch {
// If we can't stat, don't cache
}
}
/**
* Compute a hash of the inputs that affect the rendered map.
* Used to cache the final rendered output.
*/
export function computeMapHash(
files: string[],
maxTokens: number,
focusFiles: string[],
): string {
const sorted = [...files].sort()
const input = JSON.stringify({ files: sorted, maxTokens, focusFiles: [...focusFiles].sort() })
return createHash('sha1').update(input).digest('hex')
}
/** Get cache statistics. */
export function getCacheStats(root: string): CacheStats {
const cacheFile = getCacheFilePath(root)
const exists = existsSync(cacheFile)
let entryCount = 0
if (exists) {
try {
const data = JSON.parse(readFileSync(cacheFile, 'utf-8')) as CacheData
entryCount = Object.keys(data.entries).length
} catch {
// corrupted cache
}
}
return {
cacheDir: CACHE_DIR,
cacheFile: exists ? cacheFile : null,
entryCount,
exists,
}
}
/** Delete the cache for a repo root. */
export function invalidateCache(root: string): void {
const path = getCacheFilePath(root)
try {
const { unlinkSync } = require('fs')
unlinkSync(path)
} catch {
// File may not exist
}
}

View File

@@ -0,0 +1,109 @@
import { execFile } from 'child_process'
import { readdirSync } from 'fs'
import { join, relative } from 'path'
import type { SupportedLanguage } from './types.js'
const SUPPORTED_EXTENSIONS: Record<string, SupportedLanguage> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.mjs': 'javascript',
'.cjs': 'javascript',
'.py': 'python',
}
const EXCLUDED_DIRS = new Set([
'node_modules',
'dist',
'.git',
'.hg',
'.svn',
'build',
'out',
'coverage',
'__pycache__',
'.next',
'.nuxt',
'vendor',
'.worktrees',
])
const EXCLUDED_FILES = new Set([
'bun.lock',
'bun.lockb',
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
])
export function getLanguageForFile(filePath: string): SupportedLanguage | null {
const ext = filePath.substring(filePath.lastIndexOf('.'))
return SUPPORTED_EXTENSIONS[ext] ?? null
}
export function isSupportedFile(filePath: string): boolean {
return getLanguageForFile(filePath) !== null
}
/** List files using git ls-files. Returns relative paths. */
function gitLsFiles(root: string): Promise<string[]> {
return new Promise((resolve, reject) => {
execFile(
'git',
['ls-files', '--cached', '--others', '--exclude-standard'],
{ cwd: root, maxBuffer: 10 * 1024 * 1024 },
(error, stdout) => {
if (error) {
reject(error)
return
}
const files = stdout
.split('\n')
.map(f => f.trim())
.filter(f => f.length > 0)
resolve(files)
},
)
})
}
/** Walk directory tree manually as fallback when git is unavailable. */
function walkDirectory(root: string, currentDir: string = root): string[] {
const results: string[] = []
let entries: ReturnType<typeof readdirSync>
try {
entries = readdirSync(currentDir, { withFileTypes: true })
} catch {
return results
}
for (const entry of entries) {
const name = entry.name
if (entry.isDirectory()) {
if (!EXCLUDED_DIRS.has(name) && !name.startsWith('.')) {
results.push(...walkDirectory(root, join(currentDir, name)))
}
} else if (entry.isFile()) {
if (!EXCLUDED_FILES.has(name)) {
results.push(relative(root, join(currentDir, name)))
}
}
}
return results
}
/**
* Enumerate all supported source files in the repo.
* Tries git ls-files first, falls back to manual walk.
*/
export async function getRepoFiles(root: string): Promise<string[]> {
let files: string[]
try {
files = await gitLsFiles(root)
} catch {
files = walkDirectory(root)
}
return files.filter(isSupportedFile)
}

View File

@@ -0,0 +1,88 @@
import Graph from 'graphology'
import type { FileTags } from './types.js'
// Common identifiers that should contribute less weight (high IDF penalty).
const COMMON_NAMES = new Set([
'map', 'get', 'set', 'value', 'key', 'data', 'result', 'error',
'name', 'type', 'id', 'index', 'item', 'items', 'list', 'options',
'config', 'args', 'params', 'props', 'state', 'event', 'callback',
'handler', 'fn', 'func', 'self', 'this', 'ctx', 'context', 'req',
'res', 'next', 'err', 'msg', 'obj', 'arr', 'str', 'num', 'val',
'init', 'start', 'stop', 'run', 'main', 'test', 'setup', 'teardown',
'constructor', 'toString', 'valueOf', 'length', 'size', 'count',
'push', 'pop', 'shift', 'filter', 'reduce', 'forEach', 'find',
'log', 'warn', 'info', 'debug', 'trace',
])
/**
* Build a directed graph from file tags.
*
* Nodes are file paths. An edge from A to B means file A references
* a symbol defined in file B. Edge weight = refCount * idf(symbolName).
*/
export function buildGraph(allFileTags: FileTags[]): Graph {
const graph = new Graph({ multi: false, type: 'directed' })
// Build a map from symbol name → files that define it
const defIndex = new Map<string, Set<string>>()
for (const ft of allFileTags) {
for (const tag of ft.tags) {
if (tag.kind === 'def') {
let files = defIndex.get(tag.name)
if (!files) {
files = new Set()
defIndex.set(tag.name, files)
}
files.add(ft.path)
}
}
}
// Compute IDF: log(totalFiles / filesDefiningSymbol)
// Common names get an extra penalty
const totalFiles = allFileTags.length
function idf(symbolName: string): number {
const defFiles = defIndex.get(symbolName)
const docFreq = defFiles ? defFiles.size : 1
const rawIdf = Math.log(totalFiles / docFreq)
return COMMON_NAMES.has(symbolName) ? rawIdf * 0.1 : rawIdf
}
// Add all files as nodes
for (const ft of allFileTags) {
if (!graph.hasNode(ft.path)) {
graph.addNode(ft.path)
}
}
// Build edges: for each ref in a file, find where it's defined
for (const ft of allFileTags) {
// Count refs per target file
const edgeWeights = new Map<string, number>()
for (const tag of ft.tags) {
if (tag.kind !== 'ref') continue
const defFiles = defIndex.get(tag.name)
if (!defFiles) continue
const weight = idf(tag.name)
for (const defFile of defFiles) {
if (defFile === ft.path) continue // skip self-references
const current = edgeWeights.get(defFile) ?? 0
edgeWeights.set(defFile, current + weight)
}
}
for (const [target, weight] of edgeWeights) {
if (graph.hasEdge(ft.path, target)) {
graph.setEdgeAttribute(ft.path, target, 'weight',
graph.getEdgeAttribute(ft.path, target, 'weight') + weight)
} else {
graph.addEdge(ft.path, target, { weight })
}
}
}
return graph
}

View File

@@ -0,0 +1,144 @@
import {
computeMapHash,
getCachedTags,
getCacheStats as getCacheStatsImpl,
invalidateCache as invalidateCacheImpl,
loadCache,
saveCache,
setCachedTags,
} from './cache.js'
import { getRepoFiles } from './gitFiles.js'
import { buildGraph } from './graph.js'
import { rankFiles } from './pagerank.js'
import { initParser } from './parser.js'
import { renderMap } from './renderer.js'
import { extractTags } from './symbolExtractor.js'
import type { FileTags, RepoMapOptions, RepoMapResult, CacheStats } from './types.js'
const DEFAULT_MAX_TOKENS = 2048
/**
* Build a structural summary of a code repository.
*
* Walks the repo, extracts symbols via tree-sitter, builds an IDF-weighted
* reference graph, ranks files with PageRank, and renders a token-budgeted
* structural summary.
*/
export async function buildRepoMap(options: RepoMapOptions = {}): Promise<RepoMapResult> {
const startTime = Date.now()
const root = options.root ?? process.cwd()
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS
const focusFiles = options.focusFiles ?? []
// Initialize tree-sitter
await initParser()
// Get files
const files = options.files ?? await getRepoFiles(root)
const totalFileCount = files.length
// Check if we have a cached rendered map
const mapHash = computeMapHash(files, maxTokens, focusFiles)
const cache = loadCache(root)
// Check if rendered map is cached (stored as a special entry)
const renderedCacheKey = `__rendered__${mapHash}`
const renderedEntry = cache.entries[renderedCacheKey]
if (renderedEntry && renderedEntry.tags.length === 1) {
const cachedResult = renderedEntry.tags[0]!
// The cached "tag" stores the rendered map in the signature field
// and metadata in name/line fields
try {
const meta = JSON.parse(cachedResult.name)
return {
map: cachedResult.signature,
cacheHit: true,
buildTimeMs: Date.now() - startTime,
fileCount: meta.fileCount ?? 0,
totalFileCount,
tokenCount: meta.tokenCount ?? 0,
}
} catch {
// Invalid cached data, continue with full build
}
}
// Extract tags for all files (using per-file cache).
// Separate cached hits from files needing extraction.
const allFileTags: FileTags[] = []
const uncachedFiles: string[] = []
for (const file of files) {
const cachedTags = getCachedTags(cache, file, root)
if (cachedTags) {
allFileTags.push({ path: file, tags: cachedTags })
} else {
uncachedFiles.push(file)
}
}
// Process uncached files in parallel batches
const BATCH_SIZE = 50
for (let i = 0; i < uncachedFiles.length; i += BATCH_SIZE) {
const batch = uncachedFiles.slice(i, i + BATCH_SIZE)
const results = await Promise.all(
batch.map(file => extractTags(file, root).catch(() => null))
)
for (let j = 0; j < results.length; j++) {
const fileTags = results[j]
if (fileTags) {
allFileTags.push(fileTags)
setCachedTags(cache, fileTags.path, root, fileTags.tags)
}
}
}
// Build graph and rank
const graph = buildGraph(allFileTags)
const ranked = rankFiles(graph, focusFiles)
// Build a lookup map
const fileTagsMap = new Map<string, FileTags>()
for (const ft of allFileTags) {
fileTagsMap.set(ft.path, ft)
}
// Render
const { map, tokenCount, fileCount } = renderMap(ranked, fileTagsMap, maxTokens)
// Cache the rendered result
cache.entries[renderedCacheKey] = {
tags: [{
kind: 'def',
name: JSON.stringify({ fileCount, tokenCount }),
line: 0,
signature: map,
}],
mtimeMs: Date.now(),
size: 0,
}
saveCache(root, cache)
return {
map,
cacheHit: false,
buildTimeMs: Date.now() - startTime,
fileCount,
totalFileCount,
tokenCount,
}
}
/** Invalidate the disk cache for a given repo root. */
export function invalidateCache(root?: string): void {
invalidateCacheImpl(root ?? process.cwd())
}
/** Get cache statistics for a given repo root. */
export function getCacheStats(root?: string): CacheStats {
return getCacheStatsImpl(root ?? process.cwd())
}
// Re-export types for convenience
export type { RepoMapOptions, RepoMapResult, CacheStats } from './types.js'

View File

@@ -0,0 +1,57 @@
import type Graph from 'graphology'
import pagerank from 'graphology-pagerank'
export interface RankedFile {
path: string
score: number
}
/**
* Run PageRank on the file reference graph.
*
* focusFiles get a 100x boost in the personalization vector so they
* and their neighbors rank higher.
*
* Returns files sorted by score descending.
*/
export function rankFiles(
graph: Graph,
focusFiles: string[] = [],
): RankedFile[] {
if (graph.order === 0) return []
const hasPersonalization = focusFiles.length > 0
// graphology-pagerank accepts getEdgeWeight option
const scores: Record<string, number> = pagerank(graph, {
alpha: 0.85,
maxIterations: 100,
tolerance: 1e-6,
getEdgeWeight: 'weight',
})
// Apply focus boost post-hoc if focus files are specified
if (hasPersonalization) {
for (const file of focusFiles) {
if (scores[file] !== undefined) {
scores[file] *= 100
}
}
// Also boost direct neighbors of focus files
for (const file of focusFiles) {
if (!graph.hasNode(file)) continue
graph.forEachNeighbor(file, (neighbor) => {
if (scores[neighbor] !== undefined) {
scores[neighbor] *= 10
}
})
}
}
const ranked: RankedFile[] = Object.entries(scores)
.map(([path, score]) => ({ path, score }))
.sort((a, b) => b.score - a.score)
return ranked
}

View File

@@ -0,0 +1,166 @@
import { existsSync, readFileSync } from 'fs'
import { join, resolve } from 'path'
import { fileURLToPath } from 'url'
import type { SupportedLanguage } from './types.js'
// Resolve project root in both source and bundled modes.
// In source (bun test/dev): import.meta.url is src/context/repoMap/parser.ts → go up 4 levels
// In bundle (node dist/cli.mjs): import.meta.url is dist/cli.mjs → go up 2 levels
const __filename = fileURLToPath(import.meta.url)
const __projectRoot = join(
__filename,
process.env.NODE_ENV === 'test' ? '../../../../' : '../../',
)
// web-tree-sitter types
type TreeSitterParser = {
parse(input: string): { rootNode: unknown }
setLanguage(lang: unknown): void
delete(): void
}
type TreeSitterLanguage = {
query(source: string): unknown
}
// The actual module exports { Parser, Language } as named exports
let ParserClass: (new () => TreeSitterParser) & {
init(opts?: { locateFile?: (file: string) => string }): Promise<void>
} | null = null
let LanguageLoader: {
load(path: string | Uint8Array): Promise<TreeSitterLanguage>
} | null = null
let initialized = false
const languageCache = new Map<SupportedLanguage, TreeSitterLanguage>()
const queryCache = new Map<SupportedLanguage, string>()
/** Resolve the path to the tree-sitter WASM file. */
function getTreeSitterWasmPath(): string {
// Try require.resolve first (works in source mode with node_modules)
try {
const webTsDir = resolve(
require.resolve('web-tree-sitter/package.json'),
'..',
)
return join(webTsDir, 'tree-sitter.wasm')
} catch {
// Fallback: relative to project root
return join(__projectRoot, 'node_modules', 'web-tree-sitter', 'tree-sitter.wasm')
}
}
/** Resolve the path to a language WASM grammar file. */
function getLanguageWasmPath(language: SupportedLanguage): string {
const wasmName = language === 'typescript' ? 'tree-sitter-typescript' :
language === 'javascript' ? 'tree-sitter-javascript' :
`tree-sitter-${language}`
try {
const wasmDir = resolve(
require.resolve('tree-sitter-wasms/package.json'),
'..',
'out',
)
return join(wasmDir, `${wasmName}.wasm`)
} catch {
return join(__projectRoot, 'node_modules', 'tree-sitter-wasms', 'out', `${wasmName}.wasm`)
}
}
/** Resolve the path to a tag query .scm file for the given language. */
function getQueryPath(language: SupportedLanguage): string {
// Try source location first (works in both source and when queries are alongside the bundle)
const sourcePath = join(__projectRoot, 'src', 'context', 'repoMap', 'queries', `${language}-tags.scm`)
if (existsSync(sourcePath)) {
return sourcePath
}
// Fallback: relative to this file (source mode)
return join(fileURLToPath(import.meta.url), '..', 'queries', `${language}-tags.scm`)
}
/** Initialize the tree-sitter WASM module. */
export async function initParser(): Promise<void> {
if (initialized) return
try {
const mod = await import('web-tree-sitter')
ParserClass = mod.Parser as typeof ParserClass
LanguageLoader = mod.Language as typeof LanguageLoader
const wasmPath = getTreeSitterWasmPath()
await ParserClass!.init({
locateFile: () => wasmPath,
})
initialized = true
} catch (err) {
// eslint-disable-next-line no-console
console.error('[repoMap] Failed to initialize tree-sitter:', err)
throw err
}
}
/** Load a language grammar. Cached after first load. */
export async function loadLanguage(language: SupportedLanguage): Promise<TreeSitterLanguage | null> {
if (languageCache.has(language)) {
return languageCache.get(language)!
}
if (!initialized) {
await initParser()
}
try {
const wasmPath = getLanguageWasmPath(language)
const lang = await LanguageLoader!.load(wasmPath)
languageCache.set(language, lang)
return lang
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[repoMap] Failed to load ${language} grammar:`, err)
return null
}
}
/** Load the tag query for a language. Cached after first load. */
export function loadQuery(language: SupportedLanguage): string | null {
if (queryCache.has(language)) {
return queryCache.get(language)!
}
try {
const queryPath = getQueryPath(language)
const content = readFileSync(queryPath, 'utf-8')
queryCache.set(language, content)
return content
} catch {
return null
}
}
/** Create a new parser instance with the given language set. */
export async function createParser(language: SupportedLanguage): Promise<TreeSitterParser | null> {
if (!initialized) {
await initParser()
}
const lang = await loadLanguage(language)
if (!lang) return null
try {
const parser = new ParserClass!()
parser.setLanguage(lang)
return parser
} catch {
return null
}
}
/** Clear all caches (useful for testing). */
export function clearParserCaches(): void {
languageCache.clear()
queryCache.clear()
initialized = false
ParserClass = null
LanguageLoader = null
}

View File

@@ -0,0 +1,92 @@
; Source: https://github.com/Aider-AI/aider/blob/main/aider/queries/tree-sitter-languages/javascript-tags.scm
; License: MIT (Apache-2.0 dual) — see https://github.com/Aider-AI/aider/blob/main/LICENSE
; Copied for use in openclaude's repo-map feature.
(
(comment)* @doc
.
(method_definition
name: (property_identifier) @name.definition.method) @definition.method
(#not-eq? @name.definition.method "constructor")
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.method)
)
(
(comment)* @doc
.
[
(class
name: (_) @name.definition.class)
(class_declaration
name: (_) @name.definition.class)
] @definition.class
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.class)
)
(
(comment)* @doc
.
[
(function
name: (identifier) @name.definition.function)
(function_declaration
name: (identifier) @name.definition.function)
(generator_function
name: (identifier) @name.definition.function)
(generator_function_declaration
name: (identifier) @name.definition.function)
] @definition.function
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.function)
)
(
(comment)* @doc
.
(lexical_declaration
(variable_declarator
name: (identifier) @name.definition.function
value: [(arrow_function) (function)]) @definition.function)
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.function)
)
(
(comment)* @doc
.
(variable_declaration
(variable_declarator
name: (identifier) @name.definition.function
value: [(arrow_function) (function)]) @definition.function)
(#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$")
(#select-adjacent! @doc @definition.function)
)
(assignment_expression
left: [
(identifier) @name.definition.function
(member_expression
property: (property_identifier) @name.definition.function)
]
right: [(arrow_function) (function)]
) @definition.function
(pair
key: (property_identifier) @name.definition.function
value: [(arrow_function) (function)]) @definition.function
(
(call_expression
function: (identifier) @name.reference.call) @reference.call
(#not-match? @name.reference.call "^(require)$")
)
(call_expression
function: (member_expression
property: (property_identifier) @name.reference.call)
arguments: (_) @reference.call)
(new_expression
constructor: (_) @name.reference.class) @reference.class

View File

@@ -0,0 +1,16 @@
; Source: https://github.com/Aider-AI/aider/blob/main/aider/queries/tree-sitter-languages/python-tags.scm
; License: MIT (Apache-2.0 dual) — see https://github.com/Aider-AI/aider/blob/main/LICENSE
; Copied for use in openclaude's repo-map feature.
(class_definition
name: (identifier) @name.definition.class) @definition.class
(function_definition
name: (identifier) @name.definition.function) @definition.function
(call
function: [
(identifier) @name.reference.call
(attribute
attribute: (identifier) @name.reference.call)
]) @reference.call

View File

@@ -0,0 +1,45 @@
; Source: https://github.com/Aider-AI/aider/blob/main/aider/queries/tree-sitter-languages/typescript-tags.scm
; License: MIT (Apache-2.0 dual) — see https://github.com/Aider-AI/aider/blob/main/LICENSE
; Copied for use in openclaude's repo-map feature.
(function_signature
name: (identifier) @name.definition.function) @definition.function
(method_signature
name: (property_identifier) @name.definition.method) @definition.method
(abstract_method_signature
name: (property_identifier) @name.definition.method) @definition.method
(abstract_class_declaration
name: (type_identifier) @name.definition.class) @definition.class
(module
name: (identifier) @name.definition.module) @definition.module
(interface_declaration
name: (type_identifier) @name.definition.interface) @definition.interface
(type_annotation
(type_identifier) @name.reference.type) @reference.type
(new_expression
constructor: (identifier) @name.reference.class) @reference.class
(function_declaration
name: (identifier) @name.definition.function) @definition.function
(method_definition
name: (property_identifier) @name.definition.method) @definition.method
(class_declaration
name: (type_identifier) @name.definition.class) @definition.class
(interface_declaration
name: (type_identifier) @name.definition.class) @definition.class
(type_alias_declaration
name: (type_identifier) @name.definition.type) @definition.type
(enum_declaration
name: (identifier) @name.definition.enum) @definition.enum

View File

@@ -0,0 +1,72 @@
import type { FileTags, Tag } from './types.js'
import type { RankedFile } from './pagerank.js'
import { countTokens } from './tokenize.js'
/**
* Render a token-budgeted repo map from ranked files and their tags.
*
* Format per file:
* path/to/file.ts:
* ⋮
* signature line for def 1
* ⋮
* signature line for def 2
* ⋮
*
* Files that don't fit within the budget are dropped entirely.
*/
export function renderMap(
rankedFiles: RankedFile[],
fileTagsMap: Map<string, FileTags>,
maxTokens: number,
): { map: string; tokenCount: number; fileCount: number } {
const sections: string[] = []
let currentTokens = 0
let fileCount = 0
for (const { path } of rankedFiles) {
const ft = fileTagsMap.get(path)
if (!ft) continue
// Only include definitions in the rendered output
const defs = ft.tags
.filter(t => t.kind === 'def')
.sort((a, b) => a.line - b.line)
if (defs.length === 0) continue
const section = renderFileSection(path, defs)
const sectionTokens = countTokens(section)
// Would this section bust the budget?
if (currentTokens + sectionTokens > maxTokens) {
// Don't include partial files — drop entirely
break
}
sections.push(section)
currentTokens += sectionTokens
fileCount++
}
const map = sections.join('\n')
return { map, tokenCount: currentTokens, fileCount }
}
function renderFileSection(path: string, defs: Tag[]): string {
const lines: string[] = [`${path}:`]
let lastLine = 0
for (const def of defs) {
// Add elision marker if there's a gap
if (def.line > lastLine + 1) {
lines.push('⋮')
}
lines.push(` ${def.signature}`)
lastLine = def.line
}
// Trailing elision marker
lines.push('⋮')
return lines.join('\n')
}

View File

@@ -0,0 +1,275 @@
import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
import { cpSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { invalidateCache, buildRepoMap } from './index.js'
import { extractTags } from './symbolExtractor.js'
import { buildGraph } from './graph.js'
import { initParser } from './parser.js'
import { countTokens } from './tokenize.js'
const FIXTURE_ROOT = join(import.meta.dir, '__fixtures__', 'mini-repo')
const FIXTURE_FILES = ['fileA.ts', 'fileB.ts', 'fileC.ts', 'fileD.ts', 'fileE.ts']
beforeAll(async () => {
await initParser()
})
// Clean up cache between tests to avoid cross-test interference
afterEach(() => {
invalidateCache(FIXTURE_ROOT)
})
describe('symbol extraction', () => {
test('extracts function and class defs from a TypeScript file', async () => {
const result = await extractTags('fileC.ts', FIXTURE_ROOT)
expect(result).not.toBeNull()
const defs = result!.tags.filter(t => t.kind === 'def')
const defNames = defs.map(t => t.name)
expect(defNames).toContain('DataStore')
expect(defNames).toContain('createStore')
expect(defNames).toContain('StoreConfig')
// All defs should have kind='def'
for (const d of defs) {
expect(d.kind).toBe('def')
}
})
test('extracts references to imported symbols', async () => {
const result = await extractTags('fileA.ts', FIXTURE_ROOT)
expect(result).not.toBeNull()
const refs = result!.tags.filter(t => t.kind === 'ref')
const refNames = refs.map(t => t.name)
// fileA imports CacheLayer from fileB and StoreConfig from fileC
expect(refNames).toContain('CacheLayer')
expect(refNames).toContain('StoreConfig')
})
})
describe('graph', () => {
test('builds edges between files that reference each other\'s symbols', async () => {
const allTags = []
for (const f of FIXTURE_FILES) {
const tags = await extractTags(f, FIXTURE_ROOT)
if (tags) allTags.push(tags)
}
const graph = buildGraph(allTags)
// fileA imports from fileB (references CacheLayer defined in fileB)
expect(graph.hasEdge('fileA.ts', 'fileB.ts')).toBe(true)
// fileA imports from fileC (references StoreConfig, DataStore defined in fileC)
expect(graph.hasEdge('fileA.ts', 'fileC.ts')).toBe(true)
// fileB imports from fileC (references DataStore defined in fileC)
expect(graph.hasEdge('fileB.ts', 'fileC.ts')).toBe(true)
// fileD imports from fileA
expect(graph.hasEdge('fileD.ts', 'fileA.ts')).toBe(true)
// fileE is isolated — no edges to/from it
expect(graph.degree('fileE.ts')).toBe(0)
})
})
describe('pagerank', () => {
test('ranks the most-imported file highest', async () => {
const result = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens: 2048,
files: FIXTURE_FILES,
})
// The map starts with the highest-ranked file
const firstFile = result.map.split('\n')[0]
expect(firstFile).toBe('fileC.ts:')
// fileE should be ranked lowest (or near last)
const lines = result.map.split('\n')
const filePositions = FIXTURE_FILES.map(f => {
const idx = lines.findIndex(l => l === `${f}:`)
return { file: f, position: idx }
}).filter(x => x.position >= 0)
.sort((a, b) => a.position - b.position)
// fileC should be first
expect(filePositions[0]!.file).toBe('fileC.ts')
// fileE should be last (or among the last)
const lastFile = filePositions[filePositions.length - 1]!.file
expect(['fileD.ts', 'fileE.ts']).toContain(lastFile)
})
})
describe('renderer', () => {
test('respects the token budget within 5%', async () => {
const maxTokens = 500
const result = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens,
files: FIXTURE_FILES,
})
const actualTokens = countTokens(result.map)
expect(actualTokens).toBeLessThanOrEqual(maxTokens * 1.05)
expect(result.tokenCount).toBeLessThanOrEqual(maxTokens * 1.05)
})
test('drops files that don\'t fit rather than listing their names', async () => {
// Very tight budget — should only fit 1-2 files
const result = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens: 100,
files: FIXTURE_FILES,
})
// Count how many files appear as headers in the output
const fileHeaders = result.map.split('\n').filter(l => l.endsWith(':') && !l.startsWith(' '))
// Every file header in the output should have its signatures listed
for (const header of fileHeaders) {
// The file must have at least one signature line after it
const headerIdx = result.map.indexOf(header)
const afterHeader = result.map.slice(headerIdx + header.length)
// Should have content (signatures), not just the filename
expect(afterHeader.trim().length).toBeGreaterThan(0)
}
// Should have fewer files than total
expect(fileHeaders.length).toBeLessThan(FIXTURE_FILES.length)
})
})
describe('cache', () => {
test('second build of unchanged fixture uses the cache', async () => {
// First build (cold)
const result1 = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens: 2048,
files: FIXTURE_FILES,
})
expect(result1.cacheHit).toBe(false)
// Second build (warm)
const result2 = await buildRepoMap({
root: FIXTURE_ROOT,
maxTokens: 2048,
files: FIXTURE_FILES,
})
expect(result2.cacheHit).toBe(true)
expect(result2.buildTimeMs).toBeLessThan(result1.buildTimeMs)
// Output should be identical
expect(result2.map).toBe(result1.map)
})
test('modifying a file invalidates only that file', async () => {
// Create a temp copy of the fixture
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-test-'))
try {
for (const f of FIXTURE_FILES) {
cpSync(join(FIXTURE_ROOT, f), join(tempDir, f))
}
// First build
const result1 = await buildRepoMap({
root: tempDir,
maxTokens: 2048,
files: FIXTURE_FILES,
})
expect(result1.cacheHit).toBe(false)
// Touch one file to change its mtime
const targetFile = join(tempDir, 'fileE.ts')
const now = new Date()
utimesSync(targetFile, now, now)
// Second build — rendered cache should be invalidated because file list hash
// includes the files and the rendered map hash changes with different mtimes
// for the per-file cache check
invalidateCache(tempDir)
const result2 = await buildRepoMap({
root: tempDir,
maxTokens: 2048,
files: FIXTURE_FILES,
})
// The per-file cache for fileE should miss (mtime changed),
// but other files should still hit the per-file cache
expect(result2.cacheHit).toBe(false)
// Output should still be valid
expect(result2.map.length).toBeGreaterThan(0)
expect(result2.fileCount).toBe(result1.fileCount)
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
})
describe('gitFiles', () => {
test('falls back gracefully when not in a git repo', async () => {
// Create a temp directory with source files but NO .git
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-nogit-'))
try {
writeFileSync(
join(tempDir, 'hello.ts'),
'export function hello(): string { return "world" }\n',
)
writeFileSync(
join(tempDir, 'utils.ts'),
'export function add(a: number, b: number): number { return a + b }\n',
)
const result = await buildRepoMap({
root: tempDir,
maxTokens: 1024,
})
// Should succeed without throwing
expect(result.map.length).toBeGreaterThan(0)
expect(result.totalFileCount).toBeGreaterThan(0)
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
})
describe('error handling', () => {
test('no crash on malformed source file', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-malformed-'))
try {
// Valid file
writeFileSync(
join(tempDir, 'good.ts'),
'export function good(): number { return 1 }\n',
)
// Malformed file — severe syntax errors
writeFileSync(
join(tempDir, 'bad.ts'),
'}{}{}{export classclass [[[ function ,,, @@@ ###\n',
)
const result = await buildRepoMap({
root: tempDir,
maxTokens: 1024,
files: ['good.ts', 'bad.ts'],
})
// Should complete successfully
expect(result.map.length).toBeGreaterThan(0)
// The good file should be in the output
expect(result.map).toContain('good.ts')
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
})

View File

@@ -0,0 +1,108 @@
import { readFileSync } from 'fs'
import { join } from 'path'
import { getLanguageForFile } from './gitFiles.js'
import { createParser, loadLanguage, loadQuery } from './parser.js'
import type { FileTags, Tag } from './types.js'
/**
* Extract definition and reference tags from a single source file.
* Returns null if the file can't be parsed (unsupported language, parse error, etc).
*/
export async function extractTags(
filePath: string,
root: string,
): Promise<FileTags | null> {
const language = getLanguageForFile(filePath)
if (!language) return null
const absolutePath = join(root, filePath)
let source: string
try {
source = readFileSync(absolutePath, 'utf-8')
} catch {
return null
}
const lines = source.split('\n')
const parser = await createParser(language)
if (!parser) return null
const querySource = loadQuery(language)
if (!querySource) {
parser.delete()
return null
}
try {
const tree = parser.parse(source) as {
rootNode: unknown
}
const lang = await loadLanguage(language)
if (!lang) {
parser.delete()
return null
}
// Use the non-deprecated Query constructor
const { Query } = await import('web-tree-sitter')
const query = new Query(lang, querySource) as {
matches(rootNode: unknown): Array<{
pattern: number
captures: Array<{
name: string
node: {
text: string
startPosition: { row: number; column: number }
endPosition: { row: number; column: number }
}
}>
}>
}
const matches = query.matches(tree.rootNode)
const tags: Tag[] = []
const seen = new Set<string>() // dedup by kind+name+line
for (const match of matches) {
let name: string | null = null
let kind: 'def' | 'ref' | null = null
let subKind: string | undefined
let lineRow = 0
for (const capture of match.captures) {
const captureName = capture.name
// Name captures: name.definition.X or name.reference.X
if (captureName.startsWith('name.definition.')) {
name = capture.node.text
kind = 'def'
subKind = captureName.slice('name.definition.'.length)
lineRow = capture.node.startPosition.row
} else if (captureName.startsWith('name.reference.')) {
name = capture.node.text
kind = 'ref'
subKind = captureName.slice('name.reference.'.length)
lineRow = capture.node.startPosition.row
}
}
if (name && kind) {
const key = `${kind}:${name}:${lineRow}`
if (!seen.has(key)) {
seen.add(key)
const line = lineRow + 1 // convert 0-based to 1-based
const signature = lines[lineRow]?.trimEnd() ?? ''
tags.push({ kind, name, line, signature, subKind })
}
}
}
parser.delete()
return { path: filePath, tags }
} catch {
parser.delete()
return null
}
}

View File

@@ -0,0 +1,15 @@
import { getEncoding, type Tiktoken } from 'js-tiktoken'
let encoder: Tiktoken | null = null
function getEncoder() {
if (!encoder) {
encoder = getEncoding('cl100k_base')
}
return encoder
}
/** Count the number of tokens in a string using cl100k_base encoding. */
export function countTokens(text: string): number {
return getEncoder().encode(text).length
}

View File

@@ -0,0 +1,65 @@
export interface Tag {
/** 'def' for definitions, 'ref' for references */
kind: 'def' | 'ref'
/** Symbol name (e.g. function name, class name) */
name: string
/** 1-based line number in the source file */
line: number
/** The full line of source code at this position (used as signature for defs) */
signature: string
/** Sub-kind from the query (e.g. 'function', 'class', 'method', 'type') */
subKind?: string
}
export interface FileTags {
/** Relative path from the repo root */
path: string
/** All tags extracted from this file */
tags: Tag[]
}
export interface RepoMapOptions {
/** Root directory of the repo (defaults to cwd) */
root?: string
/** Maximum token budget for the rendered map */
maxTokens?: number
/** Files to boost in PageRank (relative paths) */
focusFiles?: string[]
/** Override the list of files to process (relative paths) */
files?: string[]
}
export interface RepoMapResult {
/** The rendered repo map string */
map: string
/** Whether the result came from cache */
cacheHit: boolean
/** Time in milliseconds to build the map */
buildTimeMs: number
/** Number of files included in the rendered map */
fileCount: number
/** Total number of files processed */
totalFileCount: number
/** Actual token count of the rendered map */
tokenCount: number
}
export interface CacheEntry {
tags: Tag[]
mtimeMs: number
size: number
}
export interface CacheData {
version: number
entries: Record<string, CacheEntry>
}
export interface CacheStats {
cacheDir: string
cacheFile: string | null
entryCount: number
exists: boolean
}
export type SupportedLanguage = 'typescript' | 'javascript' | 'python'

View File

@@ -0,0 +1,18 @@
import type { BuiltInAgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
import { EXPLORE_AGENT } from '../tools/AgentTool/built-in/exploreAgent.js'
import { GENERAL_PURPOSE_AGENT } from '../tools/AgentTool/built-in/generalPurposeAgent.js'
import { PLAN_AGENT } from '../tools/AgentTool/built-in/planAgent.js'
// The coordinator system prompt instructs the model to spawn workers with
// subagent_type: "worker". This agent definition matches that type so
// AgentTool.tsx can resolve it. It reuses GENERAL_PURPOSE_AGENT's capabilities.
const WORKER_AGENT: BuiltInAgentDefinition = {
...GENERAL_PURPOSE_AGENT,
agentType: 'worker',
whenToUse:
'Worker agent for coordinator mode. Executes tasks autonomously — research, implementation, or verification.',
}
export function getCoordinatorAgents(): BuiltInAgentDefinition[] {
return [WORKER_AGENT, GENERAL_PURPOSE_AGENT, EXPLORE_AGENT, PLAN_AGENT]
}

View File

@@ -0,0 +1,123 @@
import { PassThrough } from 'node:stream'
import { afterEach, expect, mock, test } from 'bun:test'
import React from 'react'
import { createRoot, Text } from '../ink.js'
type AuthState = {
anthropicAuthEnabled: boolean
claudeSubscriber: boolean
key?: string
source?: string
}
function createTestStreams(): {
stdout: PassThrough
stdin: PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
} {
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
;(stdout as unknown as { columns: number }).columns = 120
return { stdout, stdin }
}
async function waitForCondition(
predicate: () => boolean,
timeoutMs = 2000,
): Promise<void> {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (predicate()) {
return
}
await Bun.sleep(10)
}
throw new Error('Timed out waiting for useApiKeyVerification test state')
}
afterEach(() => {
mock.restore()
})
test('useApiKeyVerification resets stale missing status when the session switches to a third-party provider', async () => {
const authState: AuthState = {
anthropicAuthEnabled: true,
claudeSubscriber: false,
}
const seenStatuses: string[] = []
mock.module('../utils/auth.js', () => ({
getAnthropicApiKeyWithSource: () => ({
key: authState.key,
source: authState.source,
}),
getApiKeyFromApiKeyHelper: async () => undefined,
isAnthropicAuthEnabled: () => authState.anthropicAuthEnabled,
isClaudeAISubscriber: () => authState.claudeSubscriber,
}))
mock.module('../bootstrap/state.js', () => ({
getIsNonInteractiveSession: () => false,
}))
mock.module('../services/api/claude.js', () => ({
verifyApiKey: async () => true,
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { useApiKeyVerification } = await import(
'./useApiKeyVerification.ts?switch-to-third-party'
)
function Harness(): React.ReactNode {
const { status } = useApiKeyVerification()
React.useEffect(() => {
seenStatuses.push(status)
}, [status])
return <Text>{status}</Text>
}
const { stdout, stdin } = createTestStreams()
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
root.render(<Harness />)
await waitForCondition(() => seenStatuses.includes('missing'))
authState.anthropicAuthEnabled = false
root.render(<Harness />)
await waitForCondition(() => seenStatuses.includes('valid'))
root.unmount()
stdin.end()
stdout.end()
await Bun.sleep(0)
expect(seenStatuses[0]).toBe('missing')
expect(seenStatuses).toContain('valid')
})

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { getIsNonInteractiveSession } from '../bootstrap/state.js' import { getIsNonInteractiveSession } from '../bootstrap/state.js'
import { verifyApiKey } from '../services/api/claude.js' import { verifyApiKey } from '../services/api/claude.js'
import { import {
@@ -21,8 +21,7 @@ export type ApiKeyVerificationResult = {
error: Error | null error: Error | null
} }
export function useApiKeyVerification(): ApiKeyVerificationResult { function getInitialVerificationStatus(): VerificationStatus {
const [status, setStatus] = useState<VerificationStatus>(() => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {
return 'valid' return 'valid'
} }
@@ -37,8 +36,28 @@ export function useApiKeyVerification(): ApiKeyVerificationResult {
return 'loading' return 'loading'
} }
return 'missing' return 'missing'
}) }
export function useApiKeyVerification(): ApiKeyVerificationResult {
const [status, setStatus] = useState<VerificationStatus>(
getInitialVerificationStatus,
)
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const anthropicVerificationEnabled =
isAnthropicAuthEnabled() && !isClaudeAISubscriber()
useEffect(() => {
const nextStatus = anthropicVerificationEnabled
? getInitialVerificationStatus()
: 'valid'
setStatus(currentStatus =>
currentStatus === nextStatus ? currentStatus : nextStatus,
)
if (nextStatus !== 'error') {
setError(null)
}
}, [anthropicVerificationEnabled])
const verify = useCallback(async (): Promise<void> => { const verify = useCallback(async (): Promise<void> => {
if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) { if (!isAnthropicAuthEnabled() || isClaudeAISubscriber()) {

View File

@@ -434,7 +434,7 @@ export function useReplBridge(messages: Message[], setMessages: (action: React.S
if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) { if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) {
return { return {
ok: false, ok: false,
error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions' error: 'Cannot set permission mode to bypassPermissions. Enable it with --allow-dangerously-skip-permissions or set permissions.allowBypassPermissionsMode in settings.json'
}; };
} }
} }

View File

@@ -481,16 +481,16 @@ export const CLEAR_TAB_STATUS = osc(
) )
/** /**
* Gate for emitting OSC 21337 (tab-status indicator). Ant-only while the * Gate for emitting OSC 21337 (tab-status indicator). Currently disabled
* spec is unstable. Terminals that don't recognize it discard silently, so * (spec is unstable). Terminals that don't recognize it discard silently,
* emission is safe unconditionally — we don't gate on terminal detection * so emission is safe unconditionally — we don't gate on terminal detection
* since support is expected across several terminals. * since support is expected across several terminals.
* *
* Callers must wrap output with wrapForMultiplexer() so tmux/screen * Callers must wrap output with wrapForMultiplexer() so tmux/screen
* DCS-passthrough carries the sequence to the outer terminal. * DCS-passthrough carries the sequence to the outer terminal.
*/ */
export function supportsTabStatus(): boolean { export function supportsTabStatus(): boolean {
return process.env.USER_TYPE === 'ant' return false
} }
/** /**

View File

@@ -74,7 +74,7 @@ export function isTeamMemoryEnabled(): boolean {
if (!isAutoMemoryEnabled()) { if (!isAutoMemoryEnabled()) {
return false return false
} }
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', false) return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', true)
} }
/** /**

View File

@@ -160,6 +160,7 @@ function* yieldMissingToolResultBlocks(
* rules, ye will be punished with an entire day of debugging and hair pulling. * rules, ye will be punished with an entire day of debugging and hair pulling.
*/ */
const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3 const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3
const MAX_CONTINUATION_NUDGES = 3
/** /**
* Is this a max_output_tokens error message? If so, the streaming loop should * Is this a max_output_tokens error message? If so, the streaming loop should
@@ -209,6 +210,10 @@ type State = {
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
stopHookActive: boolean | undefined stopHookActive: boolean | undefined
turnCount: number turnCount: number
// Count of consecutive continuation nudges within the current turn.
// Capped at MAX_CONTINUATION_NUDGES to prevent infinite nudge loops
// when the model keeps matching continuation signals without tool calls.
continuationNudgeCount: number
// Why the previous iteration continued. Undefined on first iteration. // Why the previous iteration continued. Undefined on first iteration.
// Lets tests assert recovery paths fired without inspecting message contents. // Lets tests assert recovery paths fired without inspecting message contents.
transition: Continue | undefined transition: Continue | undefined
@@ -272,6 +277,7 @@ async function* queryLoop(
maxOutputTokensRecoveryCount: 0, maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false, hasAttemptedReactiveCompact: false,
turnCount: 1, turnCount: 1,
continuationNudgeCount: 0,
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
transition: undefined, transition: undefined,
} }
@@ -645,6 +651,35 @@ async function* queryLoop(
} }
} }
// Safety net: when auto-compact's circuit breaker has tripped (3+
// consecutive failures), the normal blocking check above is gated on
// reactiveCompact. If reactiveCompact is also enabled but ALSO fails
// (or is disabled), the oversized context goes straight to the API and
// gets a 500. This check catches that gap — if compaction is exhausted
// and context is still over the autocompact threshold, block immediately
// with a clear message instead of burning an API call that will 500.
if (
tracking?.consecutiveFailures !== undefined &&
tracking.consecutiveFailures >= 3 &&
isAutoCompactEnabled()
) {
const model = toolUseContext.options.mainLoopModel
const tokenUsage = tokenCountWithEstimation(messagesForQuery) - snipTokensFreed
const { isAboveAutoCompactThreshold } = calculateTokenWarningState(
tokenUsage,
model,
)
if (isAboveAutoCompactThreshold) {
yield createAssistantAPIErrorMessage({
content:
'The conversation has exceeded the context limit and automatic compaction has failed. ' +
'Press esc twice to go up a few messages and try again, or start a new session with /new.',
error: 'invalid_request',
})
return { reason: 'blocking_limit' }
}
}
let attemptWithFallback = true let attemptWithFallback = true
queryCheckpoint('query_api_loop_start') queryCheckpoint('query_api_loop_start')
@@ -1102,6 +1137,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { transition: {
reason: 'collapse_drain_retry', reason: 'collapse_drain_retry',
committed: drained.committed, committed: drained.committed,
@@ -1155,6 +1191,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { reason: 'reactive_compact_retry' }, transition: { reason: 'reactive_compact_retry' },
} }
state = next state = next
@@ -1210,6 +1247,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { reason: 'max_output_tokens_escalate' }, transition: { reason: 'max_output_tokens_escalate' },
} }
state = next state = next
@@ -1238,6 +1276,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { transition: {
reason: 'max_output_tokens_recovery', reason: 'max_output_tokens_recovery',
attempt: maxOutputTokensRecoveryCount + 1, attempt: maxOutputTokensRecoveryCount + 1,
@@ -1295,6 +1334,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: true, stopHookActive: true,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { reason: 'stop_hook_blocking' }, transition: { reason: 'stop_hook_blocking' },
} }
state = next state = next
@@ -1331,6 +1371,7 @@ async function* queryLoop(
pendingToolUseSummary: undefined, pendingToolUseSummary: undefined,
stopHookActive: undefined, stopHookActive: undefined,
turnCount, turnCount,
continuationNudgeCount: state.continuationNudgeCount,
transition: { reason: 'token_budget_continuation' }, transition: { reason: 'token_budget_continuation' },
} }
continue continue
@@ -1350,6 +1391,77 @@ async function* queryLoop(
} }
} }
// Continuation nudge: detect when the model signals intent to continue
// (e.g., "so now I have to do it", "let me now...", "I'll need to...")
// but returned no tool calls. This prevents premature task completion.
//
// Guard: capped at MAX_CONTINUATION_NUDGES to prevent infinite loops
// when the model keeps matching signals without ever calling tools.
if (
assistantMessages.length > 0 &&
turnCount < (maxTurns ?? Infinity) &&
state.continuationNudgeCount < MAX_CONTINUATION_NUDGES
) {
const lastAssistant = assistantMessages.at(-1)
if (lastAssistant?.type === 'assistant') {
const lastText = lastAssistant.message.content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map(b => b.text)
.join(' ')
.toLowerCase()
// Tightened patterns: require explicit action verbs and exclude
// common explanatory phrasing to reduce false positives.
const continuationSignals = [
// Only match "so now I/let me/we" followed by an action verb
/\bso now (i|let me|we) (need to|have to|should|must|will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up)\b/,
// "now I'll" + action (not "now I'll explain" etc.)
/\bnow i('ll| will) (do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|go|proceed)\b/,
// "let me" + action (not "let me think/explain/show")
/\blet me (go ahead and |now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up|proceed)\b/,
// "I'll/I need to/I have to" + action, only if message is short (<80 chars)
...(lastText.length < 80
? [/\b(i('ll| will| need to| have to| must) (now )?(do|create|write|edit|update|fix|implement|add|run|check|make|build|set up))\b/]
: []),
// "time to" + action
/\btime to (do|create|write|edit|update|fix|implement|add|run|check|make|build|get started|begin)\b/,
// "next, I'll/let me" + action, only if message is short
...(lastText.length < 80
? [/\bnext,?\s+(i('ll| will)|let me|i need to) (do|create|write|edit|update|fix|implement|add|run|check|make|build)\b/]
: []),
]
// Don't nudge if the text contains completion markers
const completionMarkers = /\b(done|finished|completed|complete|summary|that's all|that is all|all set|hope this helps|let me know if)\b/
if (completionMarkers.test(lastText)) {
// Model signaled completion — don't nudge
} else if (continuationSignals.some(re => re.test(lastText))) {
logForDebugging(
`Continuation nudge triggered (${state.continuationNudgeCount + 1}/${MAX_CONTINUATION_NUDGES}): model said "${lastText.slice(-120)}" without tool calls`,
)
const nudge = createUserMessage({
content: 'Continue with the task. Use the appropriate tools to proceed.',
isMeta: true,
})
const next: State = {
messages: [...messagesForQuery, ...assistantMessages, nudge],
toolUseContext,
autoCompactTracking: tracking,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
maxOutputTokensOverride: undefined,
pendingToolUseSummary: undefined,
stopHookActive: undefined,
turnCount,
continuationNudgeCount: state.continuationNudgeCount + 1,
transition: { reason: 'continuation_nudge' },
}
state = next
continue
}
}
}
return { reason: 'completed' } return { reason: 'completed' }
} }
@@ -1715,6 +1827,7 @@ async function* queryLoop(
turnCount: nextTurnCount, turnCount: nextTurnCount,
maxOutputTokensRecoveryCount: 0, maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false, hasAttemptedReactiveCompact: false,
continuationNudgeCount: 0,
pendingToolUseSummary: nextPendingToolUseSummary, pendingToolUseSummary: nextPendingToolUseSummary,
maxOutputTokensOverride: undefined, maxOutputTokensOverride: undefined,
stopHookActive, stopHookActive,

View File

@@ -196,7 +196,7 @@ const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => { };
const PROACTIVE_FALSE = () => false; const PROACTIVE_FALSE = () => false;
const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false;
const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null;
const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null; const useScheduledTasks = require('../hooks/useScheduledTasks.js').useScheduledTasks;
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js';
import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js';
@@ -4076,21 +4076,13 @@ export function REPL({
}); });
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List) // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
if (feature('AGENT_TRIGGERS')) { // and session-only /loop runs.
// Assistant mode bypasses the isLoading gate (the proactive tick →
// Sleep → tick loop would otherwise starve the scheduler).
// kairosEnabled is set once in initialState (main.tsx) and never mutated — no
// subscription needed. The tengu_kairos_cron runtime gate is checked inside
// useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic
// condition would break rules-of-hooks.
const assistantMode = store.getState().kairosEnabled; const assistantMode = store.getState().kairosEnabled;
// biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useScheduledTasks({
useScheduledTasks!({
isLoading, isLoading,
assistantMode, assistantMode,
setMessages setMessages
}); });
}
// Note: Permission polling is now handled by useInboxPoller // Note: Permission polling is now handled by useInboxPoller
// - Workers receive permission responses via mailbox messages // - Workers receive permission responses via mailbox messages

View File

@@ -116,9 +116,21 @@ async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> {
return parsed.data return parsed.data
}) })
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status ?? 'no-response'
const code = error.code ?? 'unknown-code'
const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN'
const requestUrl = error.config?.url ?? 'unknown-url'
const message = error.message ?? 'unknown axios error'
logForDebugging( logForDebugging(
`[Bootstrap] Fetch failed: ${axios.isAxiosError(error) ? (error.response?.status ?? error.code) : 'unknown'}`, `[Bootstrap] Fetch failed: status=${status} code=${code} method=${method} url=${requestUrl} message=${message}`,
) )
} else {
const message = error instanceof Error ? error.message : String(error)
logForDebugging(`[Bootstrap] Fetch failed: ${message}`)
}
throw error throw error
} }
} }

View File

@@ -0,0 +1,166 @@
import { createServer } from 'node:http'
import { afterEach, expect, mock, test } from 'bun:test'
import { CodexOAuthService } from './codexOAuth.js'
const originalFetch = globalThis.fetch
const originalCallbackPort = process.env.CODEX_OAUTH_CALLBACK_PORT
const originalClientId = process.env.CODEX_OAUTH_CLIENT_ID
afterEach(() => {
mock.restore()
globalThis.fetch = originalFetch
if (originalCallbackPort === undefined) {
delete process.env.CODEX_OAUTH_CALLBACK_PORT
} else {
process.env.CODEX_OAUTH_CALLBACK_PORT = originalCallbackPort
}
if (originalClientId === undefined) {
delete process.env.CODEX_OAUTH_CLIENT_ID
} else {
process.env.CODEX_OAUTH_CLIENT_ID = originalClientId
}
})
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to allocate test port.')))
return
}
const { port } = address
server.close(error => {
if (error) {
reject(error)
return
}
resolve(port)
})
})
})
}
function buildCallbackRequest(authUrl: string): string {
const authorizeUrl = new URL(authUrl)
const redirectUri = authorizeUrl.searchParams.get('redirect_uri')
const state = authorizeUrl.searchParams.get('state')
if (!redirectUri || !state) {
throw new Error('Codex OAuth test did not receive a valid authorization URL.')
}
const callbackUrl = new URL(redirectUri)
callbackUrl.searchParams.set('code', 'auth-code')
callbackUrl.searchParams.set('state', state)
return callbackUrl.toString()
}
test('serves updated success copy after a successful Codex OAuth flow', async () => {
const callbackPort = await getFreePort()
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
globalThis.fetch = mock(async (input, init) => {
const url = String(input)
if (url.startsWith('http://localhost:')) {
return originalFetch(input, init)
}
return new Response(
JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)
}) as typeof fetch
const service = new CodexOAuthService()
let callbackResponsePromise!: Promise<Response>
const flowPromise = service.startOAuthFlow(async authUrl => {
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
})
const tokens = await flowPromise
const callbackResponse = await callbackResponsePromise
const html = await callbackResponse.text()
expect(tokens.accessToken).toBe('access-token')
expect(tokens.refreshToken).toBe('refresh-token')
expect(html).toContain('You can return to OpenClaude now.')
expect(html).toContain(
'OpenClaude will finish activating your new Codex OAuth login.',
)
expect(html).not.toContain('continue automatically')
})
test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => {
const callbackPort = await getFreePort()
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
let resolveFetchStart!: () => void
const fetchStarted = new Promise<void>(resolve => {
resolveFetchStart = resolve
})
globalThis.fetch = mock((input, init) => {
const url = String(input)
if (url.startsWith('http://localhost:')) {
return originalFetch(input, init)
}
return new Promise<Response>((_resolve, reject) => {
resolveFetchStart()
const signal = init?.signal
if (!signal) {
return
}
if (signal.aborted) {
reject(signal.reason)
return
}
signal.addEventListener(
'abort',
() => {
reject(signal.reason)
},
{ once: true },
)
})
}) as typeof fetch
const service = new CodexOAuthService()
let callbackResponsePromise!: Promise<Response>
const flowPromise = service.startOAuthFlow(async authUrl => {
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
})
await fetchStarted
service.cleanup()
await expect(flowPromise).rejects.toThrow('Codex OAuth flow was cancelled.')
const callbackResponse = await callbackResponsePromise
const html = await callbackResponse.text()
expect(html).toContain('Codex login cancelled')
expect(html).toContain('retry in OpenClaude')
})

View File

@@ -0,0 +1,307 @@
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from '../oauth/crypto.js'
import {
asTrimmedString,
CODEX_OAUTH_ISSUER,
CODEX_OAUTH_ORIGINATOR,
CODEX_OAUTH_SCOPE,
escapeHtml,
exchangeCodexIdTokenForApiKey,
getCodexOAuthCallbackPort,
getCodexOAuthClientId,
parseChatgptAccountId,
} from './codexOAuthShared.js'
type CodexOAuthTokenResponse = {
id_token?: string
access_token?: string
refresh_token?: string
}
export type CodexOAuthTokens = {
apiKey?: string
accessToken: string
refreshToken: string
idToken?: string
accountId?: string
}
function buildCodexAuthorizeUrl(options: {
port: number
codeChallenge: string
state: string
}): string {
const redirectUri = `http://localhost:${options.port}/auth/callback`
const authUrl = new URL(`${CODEX_OAUTH_ISSUER}/oauth/authorize`)
authUrl.searchParams.append('response_type', 'code')
authUrl.searchParams.append('client_id', getCodexOAuthClientId())
authUrl.searchParams.append('redirect_uri', redirectUri)
authUrl.searchParams.append('scope', CODEX_OAUTH_SCOPE)
authUrl.searchParams.append('code_challenge', options.codeChallenge)
authUrl.searchParams.append('code_challenge_method', 'S256')
authUrl.searchParams.append('id_token_add_organizations', 'true')
authUrl.searchParams.append('codex_cli_simplified_flow', 'true')
authUrl.searchParams.append('state', options.state)
authUrl.searchParams.append('originator', CODEX_OAUTH_ORIGINATOR)
return authUrl.toString()
}
function renderSuccessPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Complete</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login complete</h1>
<p>You can return to OpenClaude now.</p>
<p>OpenClaude will finish activating your new Codex OAuth login.</p>
</body>
</html>`
}
function renderErrorPage(message: string): string {
const safeMessage = escapeHtml(message)
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Failed</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; color: #991b1b; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login failed</h1>
<p>${safeMessage}</p>
<p>You can close this window and try again in OpenClaude.</p>
</body>
</html>`
}
function renderCancelledPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Codex Login Cancelled</title>
<style>
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 0 0 10px; }
</style>
</head>
<body>
<h1>Codex login cancelled</h1>
<p>You can close this window and retry in OpenClaude.</p>
</body>
</html>`
}
async function exchangeAuthorizationCode(options: {
authorizationCode: string
codeVerifier: string
port: number
signal?: AbortSignal
}): Promise<CodexOAuthTokens> {
const redirectUri = `http://localhost:${options.port}/auth/callback`
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: options.authorizationCode,
redirect_uri: redirectUri,
client_id: getCodexOAuthClientId(),
code_verifier: options.codeVerifier,
})
const response = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal: options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(15_000)])
: AbortSignal.timeout(15_000),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(
errorText.trim()
? `Codex OAuth token exchange failed (${response.status}): ${errorText.trim()}`
: `Codex OAuth token exchange failed with status ${response.status}.`,
)
}
const payload = (await response.json()) as CodexOAuthTokenResponse
const accessToken = asTrimmedString(payload.access_token)
const refreshToken = asTrimmedString(payload.refresh_token)
if (!accessToken || !refreshToken) {
throw new Error(
'Codex OAuth completed, but the token response was missing credentials.',
)
}
const idToken = asTrimmedString(payload.id_token)
const apiKey = idToken
? await exchangeCodexIdTokenForApiKey(idToken).catch(() => undefined)
: undefined
return {
apiKey,
accessToken,
refreshToken,
idToken,
accountId:
parseChatgptAccountId(idToken) ?? parseChatgptAccountId(accessToken),
}
}
export class CodexOAuthService {
private authCodeListener: AuthCodeListener | null = null
private port: number | null = null
private tokenExchangeAbortController: AbortController | null = null
private buildCancellationError(): Error {
return new Error('Codex OAuth flow was cancelled.')
}
async startOAuthFlow(
authURLHandler: (authUrl: string) => Promise<void>,
): Promise<CodexOAuthTokens> {
const codeVerifier = generateCodeVerifier()
const callbackPort = getCodexOAuthCallbackPort()
const authCodeListener = new AuthCodeListener('/auth/callback')
this.authCodeListener = authCodeListener
this.port = null
try {
const port = await authCodeListener.start(callbackPort)
this.port = port
const state = generateState()
const codeChallenge = await generateCodeChallenge(codeVerifier)
const authUrl = buildCodexAuthorizeUrl({
port,
codeChallenge,
state,
})
try {
const authorizationCode = await authCodeListener.waitForAuthorization(
state,
async () => {
await authURLHandler(authUrl)
},
)
const tokenExchangeAbortController = new AbortController()
this.tokenExchangeAbortController = tokenExchangeAbortController
let tokens: CodexOAuthTokens
try {
tokens = await exchangeAuthorizationCode({
authorizationCode,
codeVerifier,
port,
signal: tokenExchangeAbortController.signal,
})
} finally {
if (
this.tokenExchangeAbortController === tokenExchangeAbortController
) {
this.tokenExchangeAbortController = null
}
}
if (this.authCodeListener !== authCodeListener) {
throw this.buildCancellationError()
}
authCodeListener.handleSuccessRedirect([], res => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(renderSuccessPage())
})
return tokens
} catch (error) {
const resolvedError =
this.authCodeListener === authCodeListener
? error
: this.buildCancellationError()
if (authCodeListener.hasPendingResponse()) {
const isCancellation =
resolvedError instanceof Error &&
resolvedError.message === 'Codex OAuth flow was cancelled.'
authCodeListener.handleErrorRedirect(res => {
res.writeHead(isCancellation ? 200 : 400, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(
isCancellation
? renderCancelledPage()
: renderErrorPage(
resolvedError instanceof Error
? resolvedError.message
: String(resolvedError),
),
)
})
}
throw resolvedError
} finally {
this.cleanup()
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (
message.includes('EADDRINUSE') ||
message.includes(String(callbackPort))
) {
throw new Error(
`Codex OAuth needs localhost:${callbackPort} for its callback. Close any app already using that port and try again.`,
)
}
throw error
}
}
cleanup(): void {
const cancellationError = this.buildCancellationError()
this.tokenExchangeAbortController?.abort(cancellationError)
this.tokenExchangeAbortController = null
if (this.authCodeListener?.hasPendingResponse()) {
this.authCodeListener.handleErrorRedirect(res => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
})
res.end(renderCancelledPage())
})
}
this.authCodeListener?.cancelPendingAuthorization(cancellationError)
this.authCodeListener = null
this.port = null
}
}

View File

@@ -0,0 +1,139 @@
export const CODEX_OAUTH_ISSUER = 'https://auth.openai.com'
export const CODEX_REFRESH_URL = `${CODEX_OAUTH_ISSUER}/oauth/token`
export const DEFAULT_CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
export const DEFAULT_CODEX_OAUTH_CALLBACK_PORT = 1455
export const CODEX_OAUTH_SCOPE =
'openid profile email offline_access api.connectors.read api.connectors.invoke'
export const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs'
export const CODEX_API_KEY_TOKEN_NAME = 'openai-api-key'
export const CODEX_ID_TOKEN_SUBJECT_TYPE =
'urn:ietf:params:oauth:token-type:id_token'
export const CODEX_TOKEN_EXCHANGE_GRANT =
'urn:ietf:params:oauth:grant-type:token-exchange'
export function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
export function getCodexOAuthClientId(
env: NodeJS.ProcessEnv = process.env,
): string {
return asTrimmedString(env.CODEX_OAUTH_CLIENT_ID) ?? DEFAULT_CODEX_OAUTH_CLIENT_ID
}
export function getCodexOAuthCallbackPort(
env: NodeJS.ProcessEnv = process.env,
): number {
const rawPort = asTrimmedString(env.CODEX_OAUTH_CALLBACK_PORT)
if (!rawPort) {
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
}
const parsed = Number.parseInt(rawPort, 10)
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
return parsed
}
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
}
export function decodeJwtPayload(
token: string,
): Record<string, unknown> | undefined {
const parts = token.split('.')
if (parts.length < 2) return undefined
try {
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const json = Buffer.from(padded, 'base64').toString('utf8')
const parsed = JSON.parse(json)
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: undefined
} catch {
return undefined
}
}
export function parseChatgptAccountId(
token: string | undefined,
): string | undefined {
if (!token) return undefined
const payload = decodeJwtPayload(token)
const nestedAuth =
payload?.['https://api.openai.com/auth'] &&
typeof payload['https://api.openai.com/auth'] === 'object'
? (payload['https://api.openai.com/auth'] as Record<string, unknown>)
: undefined
return (
asTrimmedString(
nestedAuth?.chatgpt_account_id ??
payload?.['https://api.openai.com/auth.chatgpt_account_id'] ??
payload?.chatgpt_account_id,
) ?? undefined
)
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, char => {
switch (char) {
case '&':
return '&amp;'
case '<':
return '&lt;'
case '>':
return '&gt;'
case '"':
return '&quot;'
case '\'':
return '&#39;'
default:
return char
}
})
}
export async function exchangeCodexIdTokenForApiKey(
idToken: string,
): Promise<string> {
const body = new URLSearchParams({
grant_type: CODEX_TOKEN_EXCHANGE_GRANT,
client_id: getCodexOAuthClientId(),
requested_token: CODEX_API_KEY_TOKEN_NAME,
subject_token: idToken,
subject_token_type: CODEX_ID_TOKEN_SUBJECT_TYPE,
})
const response = await fetch(CODEX_REFRESH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
const bodyText = await response.text().catch(() => '')
throw new Error(
bodyText.trim()
? `Codex API key exchange failed (${response.status}): ${bodyText.trim()}`
: `Codex API key exchange failed with status ${response.status}.`,
)
}
const payload = (await response.json()) as { access_token?: string }
const apiKey = asTrimmedString(payload.access_token)
if (!apiKey) {
throw new Error(
'Codex API key exchange completed, but no API key token was returned.',
)
}
return apiKey
}

View File

@@ -8,16 +8,13 @@ import {
convertCodexResponseToAnthropicMessage, convertCodexResponseToAnthropicMessage,
convertToolsToResponsesTools, convertToolsToResponsesTools,
} from './codexShim.js' } from './codexShim.js'
import {
resolveCodexApiCredentials,
resolveProviderRequest,
} from './providerConfig.js'
const tempDirs: string[] = [] const tempDirs: string[] = []
const originalEnv = { const originalEnv = {
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_BASE: process.env.OPENAI_API_BASE,
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
OPENAI_MODEL: process.env.OPENAI_MODEL,
} }
afterEach(() => { afterEach(() => {
@@ -30,6 +27,9 @@ afterEach(() => {
if (originalEnv.CLAUDE_CODE_USE_GITHUB === undefined) delete process.env.CLAUDE_CODE_USE_GITHUB if (originalEnv.CLAUDE_CODE_USE_GITHUB === undefined) delete process.env.CLAUDE_CODE_USE_GITHUB
else process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB else process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL
else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
while (tempDirs.length > 0) { while (tempDirs.length > 0) {
const dir = tempDirs.pop() const dir = tempDirs.pop()
if (dir) rmSync(dir, { recursive: true, force: true }) if (dir) rmSync(dir, { recursive: true, force: true })
@@ -59,6 +59,10 @@ async function collectStreamEventTypes(responseText: string): Promise<string[]>
return events return events
} }
async function importFreshProviderConfigModule() {
return import(`./providerConfig.js?ts=${Date.now()}-${Math.random()}`)
}
describe('Codex provider config', () => { describe('Codex provider config', () => {
const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL const originalOpenaiBaseUrl = process.env.OPENAI_BASE_URL
const originalOpenaiApiBase = process.env.OPENAI_API_BASE const originalOpenaiApiBase = process.env.OPENAI_API_BASE
@@ -75,7 +79,8 @@ describe('Codex provider config', () => {
else process.env.OPENAI_API_BASE = originalOpenaiApiBase else process.env.OPENAI_API_BASE = originalOpenaiApiBase
}) })
test('resolves codexplan alias to Codex transport with reasoning', () => { test('resolves codexplan alias to Codex transport with reasoning', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_GITHUB delete process.env.CLAUDE_CODE_USE_GITHUB
@@ -84,9 +89,23 @@ describe('Codex provider config', () => {
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
expect(resolved.resolvedModel).toBe('gpt-5.4') expect(resolved.resolvedModel).toBe('gpt-5.4')
expect(resolved.reasoning).toEqual({ effort: 'high' }) expect(resolved.reasoning).toEqual({ effort: 'high' })
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
}) })
test('does not force Codex transport when a local non-Codex base URL is explicit', () => { test('resolves codexspark alias to Codex transport with Codex base URL', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_GITHUB
const resolved = resolveProviderRequest({ model: 'codexspark' })
expect(resolved.transport).toBe('codex_responses')
expect(resolved.resolvedModel).toBe('gpt-5.3-codex-spark')
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
})
test('does not force Codex transport when a local non-Codex base URL is explicit', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
const resolved = resolveProviderRequest({ const resolved = resolveProviderRequest({
model: 'codexplan', model: 'codexplan',
baseUrl: 'http://127.0.0.1:8080/v1', baseUrl: 'http://127.0.0.1:8080/v1',
@@ -97,7 +116,8 @@ describe('Codex provider config', () => {
expect(resolved.resolvedModel).toBe('gpt-5.4') expect(resolved.resolvedModel).toBe('gpt-5.4')
}) })
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', () => { test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is the string "undefined"', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
// On Windows, env vars can leak as the literal string "undefined" instead of // On Windows, env vars can leak as the literal string "undefined" instead of
// the JS value undefined when not properly unset (issue #336). // the JS value undefined when not properly unset (issue #336).
process.env.OPENAI_BASE_URL = 'undefined' process.env.OPENAI_BASE_URL = 'undefined'
@@ -105,20 +125,57 @@ describe('Codex provider config', () => {
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
}) })
test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', () => { test('resolves codexplan to Codex transport even when OPENAI_BASE_URL is an empty string', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_BASE_URL = '' process.env.OPENAI_BASE_URL = ''
const resolved = resolveProviderRequest({ model: 'codexplan' }) const resolved = resolveProviderRequest({ model: 'codexplan' })
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
}) })
test('prefers explicit baseUrl option over env var', () => { test('prefers explicit baseUrl option over env var', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_BASE_URL = 'https://example.com/v1' process.env.OPENAI_BASE_URL = 'https://example.com/v1'
const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' }) const resolved = resolveProviderRequest({ model: 'codexplan', baseUrl: 'https://chatgpt.com/backend-api/codex' })
expect(resolved.transport).toBe('codex_responses') expect(resolved.transport).toBe('codex_responses')
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex') expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
}) })
test('loads Codex credentials from auth.json fallback', () => { test('default gpt-4o uses OpenAI base URL (no regression)', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
delete process.env.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GITHUB
const resolved = resolveProviderRequest({ model: 'gpt-4o' })
expect(resolved.transport).toBe('chat_completions')
expect(resolved.baseUrl).toBe('https://api.openai.com/v1')
expect(resolved.resolvedModel).toBe('gpt-4o')
})
test('resolves codexplan from env var OPENAI_MODEL to Codex endpoint', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_MODEL = 'codexplan'
delete process.env.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GITHUB
const resolved = resolveProviderRequest()
expect(resolved.transport).toBe('codex_responses')
expect(resolved.baseUrl).toBe('https://chatgpt.com/backend-api/codex')
expect(resolved.resolvedModel).toBe('gpt-5.4')
})
test('does not override custom base URL for codexplan (e.g., local provider)', async () => {
const { resolveProviderRequest } = await importFreshProviderConfigModule()
process.env.OPENAI_MODEL = 'codexplan'
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
delete process.env.CLAUDE_CODE_USE_GITHUB
const resolved = resolveProviderRequest()
expect(resolved.transport).toBe('chat_completions')
expect(resolved.baseUrl).toBe('http://localhost:11434/v1')
})
test('loads Codex credentials from auth.json fallback', async () => {
const { resolveCodexApiCredentials } = await importFreshProviderConfigModule()
const authPath = createTempAuthJson({ const authPath = createTempAuthJson({
tokens: { tokens: {
access_token: 'header.payload.signature', access_token: 'header.payload.signature',
@@ -134,6 +191,31 @@ describe('Codex provider config', () => {
expect(credentials.accountId).toBe('acct_test') expect(credentials.accountId).toBe('acct_test')
expect(credentials.source).toBe('auth.json') expect(credentials.source).toBe('auth.json')
}) })
test('does not treat auth.json id_token as a Codex bearer credential', async () => {
const { resolveCodexApiCredentials } = await importFreshProviderConfigModule()
const idTokenPayload = Buffer.from(
JSON.stringify({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_from_id_token',
},
}),
'utf8',
).toString('base64url')
const authPath = createTempAuthJson({
tokens: {
id_token: `header.${idTokenPayload}.signature`,
},
})
const credentials = resolveCodexApiCredentials({
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('')
expect(credentials.accountId).toBe('acct_from_id_token')
expect(credentials.source).toBe('none')
})
}) })
describe('Codex request translation', () => { describe('Codex request translation', () => {

View File

@@ -580,15 +580,55 @@ export async function performCodexRequest(options: {
return response return response
} }
async function* readSseEvents(response: Response): AsyncGenerator<CodexSseEvent> { async function* readSseEvents(response: Response, signal?: AbortSignal): AsyncGenerator<CodexSseEvent> {
const reader = response.body?.getReader() const reader = response.body?.getReader()
if (!reader) return if (!reader) return
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
const STREAM_IDLE_TIMEOUT_MS = 120_000 // 2 minutes without data
let lastDataTime = Date.now()
/**
* Read from the stream with an idle timeout. Respects the caller's
* AbortSignal — clears the idle timer on abort so the AbortError
* surfaces cleanly instead of a spurious idle timeout.
*/
async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array>> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
const elapsed = Math.round((Date.now() - lastDataTime) / 1000)
reject(new Error(
`Codex SSE stream idle for ${elapsed}s (limit: ${STREAM_IDLE_TIMEOUT_MS / 1000}s). Connection likely dropped.`,
))
}, STREAM_IDLE_TIMEOUT_MS)
let abortCleanup: (() => void) | undefined
if (signal) {
abortCleanup = () => {
clearTimeout(timeoutId)
}
signal.addEventListener('abort', abortCleanup, { once: true })
}
reader.read().then(
result => {
clearTimeout(timeoutId)
if (signal && abortCleanup) signal.removeEventListener('abort', abortCleanup)
if (result.value) lastDataTime = Date.now()
resolve(result)
},
err => {
clearTimeout(timeoutId)
if (signal && abortCleanup) signal.removeEventListener('abort', abortCleanup)
reject(err)
},
)
})
}
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await readWithTimeout()
if (done) break if (done) break
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
@@ -649,10 +689,11 @@ function determineStopReason(
export async function collectCodexCompletedResponse( export async function collectCodexCompletedResponse(
response: Response, response: Response,
signal?: AbortSignal,
): Promise<Record<string, any>> { ): Promise<Record<string, any>> {
let completedResponse: Record<string, any> | undefined let completedResponse: Record<string, any> | undefined
for await (const event of readSseEvents(response)) { for await (const event of readSseEvents(response, signal)) {
if (event.event === 'response.failed') { if (event.event === 'response.failed') {
const msg = event.data?.response?.error?.message ?? const msg = event.data?.response?.error?.message ??
event.data?.error?.message ?? 'Codex response failed' event.data?.error?.message ?? 'Codex response failed'
@@ -681,6 +722,7 @@ export async function collectCodexCompletedResponse(
export async function* codexStreamToAnthropic( export async function* codexStreamToAnthropic(
response: Response, response: Response,
model: string, model: string,
signal?: AbortSignal,
): AsyncGenerator<AnthropicStreamEvent> { ): AsyncGenerator<AnthropicStreamEvent> {
const messageId = makeMessageId() const messageId = makeMessageId()
const toolBlocksByItemId = new Map< const toolBlocksByItemId = new Map<
@@ -742,7 +784,7 @@ export async function* codexStreamToAnthropic(
}, },
} }
for await (const event of readSseEvents(response)) { for await (const event of readSseEvents(response, signal)) {
const payload = event.data const payload = event.data
if (event.event === 'response.output_item.added') { if (event.event === 'response.output_item.added') {

View File

@@ -1,7 +1,13 @@
import {
readCodexCredentialsAsync,
refreshCodexAccessTokenIfNeeded,
} from '../../utils/codexCredentials.js'
import { logForDebugging } from '../../utils/debug.js'
import { isBareMode } from '../../utils/envUtils.js'
import { import {
DEFAULT_CODEX_BASE_URL, DEFAULT_CODEX_BASE_URL,
isCodexBaseUrl, isCodexBaseUrl,
resolveCodexApiCredentials, resolveRuntimeCodexCredentials,
resolveProviderRequest, resolveProviderRequest,
} from './providerConfig.js' } from './providerConfig.js'
@@ -391,6 +397,18 @@ export function getCodexUsageUrl(baseUrl = DEFAULT_CODEX_BASE_URL): string {
} }
export async function fetchCodexUsage(): Promise<CodexUsageData> { export async function fetchCodexUsage(): Promise<CodexUsageData> {
const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
async error => {
logForDebugging(
`[codex] access token refresh failed before usage fetch: ${error instanceof Error ? error.message : String(error)}`,
{ level: 'warn' },
)
return {
refreshed: false,
credentials: await readCodexCredentialsAsync(),
}
},
)
const request = resolveProviderRequest({ const request = resolveProviderRequest({
model: process.env.OPENAI_MODEL, model: process.env.OPENAI_MODEL,
baseUrl: process.env.OPENAI_BASE_URL, baseUrl: process.env.OPENAI_BASE_URL,
@@ -401,16 +419,19 @@ export async function fetchCodexUsage(): Promise<CodexUsageData> {
) )
} }
const credentials = resolveCodexApiCredentials() const credentials = resolveRuntimeCodexCredentials({
storedCredentials: refreshResult.credentials,
})
if (!credentials.apiKey) { if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath const authHint = credentials.authPath
? ` or place a Codex auth.json at ${credentials.authPath}` ? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
: '' : oauthHint
throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`) throw new Error(`Codex auth is required. Set CODEX_API_KEY${authHint}.`)
} }
if (!credentials.accountId) { if (!credentials.accountId) {
throw new Error( throw new Error(
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.', 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
) )
} }

View File

@@ -924,6 +924,30 @@ export function getAssistantMessageFromError(
}) })
} }
// 500 errors caused by context overflow — the API returns 500 instead of 400
// when the request body (including conversation context) exceeds limits.
// This happens when auto-compact fails or the token estimation undercounts.
// Detect by checking for context-related keywords in 500 responses.
if (
error instanceof APIError &&
error.status >= 500 &&
(error.message.toLowerCase().includes('too many tokens') ||
error.message.toLowerCase().includes('request too large') ||
error.message.toLowerCase().includes('context length') ||
error.message.toLowerCase().includes('maximum context') ||
error.message.toLowerCase().includes('input length') ||
error.message.toLowerCase().includes('payload too large'))
) {
const rewindInstruction = getIsNonInteractiveSession()
? ''
: ' Press esc twice to go up a few messages, or run /compact to reduce context.'
return createAssistantAPIErrorMessage({
content: `The conversation has grown too large for the API to process.${rewindInstruction} Alternatively, start a new session with /new.`,
error: 'invalid_request',
errorDetails: `Context overflow (500): ${error.message}`,
})
}
// Connection errors (non-timeout) — use formatAPIError for detailed messages // Connection errors (non-timeout) — use formatAPIError for detailed messages
if (error instanceof APIConnectionError) { if (error instanceof APIConnectionError) {
return createAssistantAPIErrorMessage({ return createAssistantAPIErrorMessage({

View File

@@ -403,6 +403,97 @@ test('preserves usage from final OpenAI stream chunk with empty choices', async
expect(usageEvent?.usage?.output_tokens).toBe(45) expect(usageEvent?.usage?.output_tokens).toBe(45)
}) })
test('uses max_tokens instead of max_completion_tokens for local providers', async () => {
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
globalThis.fetch = (async (_input, init) => {
const body = JSON.parse(String(init?.body))
expect(body.max_tokens).toBe(64)
expect(body.max_completion_tokens).toBeUndefined()
expect(body.stream_options).toBeUndefined()
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'llama3.1:8b',
choices: [
{
message: {
role: 'assistant',
content: 'hello',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 1,
total_tokens: 6,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'llama3.1:8b',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
})
test('keeps max_completion_tokens for non-local non-github providers', async () => {
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
globalThis.fetch = (async (_input, init) => {
const body = JSON.parse(String(init?.body))
expect(body.max_completion_tokens).toBe(64)
expect(body.max_tokens).toBeUndefined()
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'gpt-4o',
choices: [
{
message: {
role: 'assistant',
content: 'hello',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 1,
total_tokens: 6,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 64,
stream: false,
})
})
test('preserves Gemini tool call extra_content in follow-up requests', async () => { test('preserves Gemini tool call extra_content in follow-up requests', async () => {
let requestBody: Record<string, unknown> | undefined let requestBody: Record<string, unknown> | undefined
@@ -689,9 +780,117 @@ test('preserves image tool results as placeholders in follow-up requests', async
const toolMessage = (requestBody?.messages as Array<Record<string, unknown>>).find( const toolMessage = (requestBody?.messages as Array<Record<string, unknown>>).find(
message => message.role === 'tool', message => message.role === 'tool',
) as { content?: string } | undefined ) as {
content?: Array<{
type: string
text?: string
image_url?: { url: string }
}> | string
} | undefined
expect(toolMessage?.content).toContain('[image:image/png]') expect(Array.isArray(toolMessage?.content)).toBe(true)
const parts = toolMessage?.content as Array<{
type: string
text?: string
image_url?: { url: string }
}>
const imagePart = parts.find(part => part.type === 'image_url')
expect(imagePart?.image_url?.url).toBe('data:image/png;base64,ZmFrZQ==')
})
test('preserves mixed text and image tool results as multipart content', async () => {
let requestBody: Record<string, unknown> | undefined
globalThis.fetch = (async (_input, init) => {
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'gpt-4o',
choices: [
{
message: {
role: 'assistant',
content: 'done',
},
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
await client.beta.messages.create({
model: 'gpt-4o',
system: 'test system',
messages: [
{ role: 'user', content: 'Read this screenshot' },
{
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'call_image_2',
name: 'Read',
input: { file_path: 'C:\\temp\\screenshot.png' },
},
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'call_image_2',
content: [
{ type: 'text', text: 'Screenshot captured' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'ZmFrZQ==',
},
},
],
},
],
},
],
max_tokens: 64,
stream: false,
})
const toolMessage = (requestBody?.messages as Array<Record<string, unknown>>).find(
message => message.role === 'tool',
) as {
content?: Array<{
type: string
text?: string
image_url?: { url: string }
}>
} | undefined
expect(Array.isArray(toolMessage?.content)).toBe(true)
const parts = toolMessage?.content ?? []
expect(parts[0]).toEqual({ type: 'text', text: 'Screenshot captured' })
expect(parts[1]).toEqual({
type: 'image_url',
image_url: { url: 'data:image/png;base64,ZmFrZQ==' },
})
}) })
test('uses GEMINI_ACCESS_TOKEN for Gemini OpenAI-compatible requests', async () => { test('uses GEMINI_ACCESS_TOKEN for Gemini OpenAI-compatible requests', async () => {

View File

@@ -22,7 +22,12 @@
*/ */
import { APIError } from '@anthropic-ai/sdk' import { APIError } from '@anthropic-ai/sdk'
import { isEnvTruthy } from '../../utils/envUtils.js' import {
readCodexCredentialsAsync,
refreshCodexAccessTokenIfNeeded,
} from '../../utils/codexCredentials.js'
import { logForDebugging } from '../../utils/debug.js'
import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js'
import { resolveGeminiCredential } from '../../utils/geminiAuth.js' import { resolveGeminiCredential } from '../../utils/geminiAuth.js'
import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js' import { hydrateGeminiAccessTokenFromSecureStorage } from '../../utils/geminiCredentials.js'
import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js' import { hydrateGithubModelsTokenFromSecureStorage } from '../../utils/githubModelsCredentials.js'
@@ -44,7 +49,7 @@ import {
} from './codexShim.js' } from './codexShim.js'
import { import {
isLocalProviderUrl, isLocalProviderUrl,
resolveCodexApiCredentials, resolveRuntimeCodexCredentials,
resolveProviderRequest, resolveProviderRequest,
getGithubEndpointType, getGithubEndpointType,
} from './providerConfig.js' } from './providerConfig.js'
@@ -176,35 +181,61 @@ function convertSystemPrompt(
return String(system) return String(system)
} }
function convertToolResultContent(content: unknown): string { function convertToolResultContent(
if (typeof content === 'string') return content content: unknown,
if (!Array.isArray(content)) return JSON.stringify(content ?? '') isError?: boolean,
): string | Array<{ type: string; text?: string; image_url?: { url: string } }> {
if (typeof content === 'string') {
return isError ? `Error: ${content}` : content
}
if (!Array.isArray(content)) {
const text = JSON.stringify(content ?? '')
return isError ? `Error: ${text}` : text
}
const chunks: string[] = [] const parts: Array<{
type: string
text?: string
image_url?: { url: string }
}> = []
for (const block of content) { for (const block of content) {
if (block?.type === 'text' && typeof block.text === 'string') { if (block?.type === 'text' && typeof block.text === 'string') {
chunks.push(block.text) parts.push({ type: 'text', text: block.text })
continue continue
} }
if (block?.type === 'image') { if (block?.type === 'image') {
const source = block.source const source = block.source
if (source?.type === 'url' && source.url) { if (source?.type === 'url' && source.url) {
chunks.push(`[Image](${source.url})`) parts.push({ type: 'image_url', image_url: { url: source.url } })
} else if (source?.type === 'base64') { } else if (source?.type === 'base64' && source.media_type && source.data) {
chunks.push(`[image:${source.media_type ?? 'unknown'}]`) parts.push({
} else { type: 'image_url',
chunks.push('[image]') image_url: {
url: `data:${source.media_type};base64,${source.data}`,
},
})
} }
continue continue
} }
if (typeof block?.text === 'string') { if (typeof block?.text === 'string') {
chunks.push(block.text) parts.push({ type: 'text', text: block.text })
} }
} }
return chunks.join('\n') if (parts.length === 0) return ''
if (parts.length === 1 && parts[0].type === 'text') {
const text = parts[0].text ?? ''
return isError ? `Error: ${text}` : text
}
if (isError && parts[0]?.type === 'text') {
parts[0] = { ...parts[0], text: `Error: ${parts[0].text ?? ''}` }
} else if (isError) {
parts.unshift({ type: 'text', text: 'Error:' })
}
return parts
} }
function convertContentBlocks( function convertContentBlocks(
@@ -292,11 +323,10 @@ function convertMessages(
// Emit tool results as tool messages // Emit tool results as tool messages
for (const tr of toolResults) { for (const tr of toolResults) {
const trContent = convertToolResultContent(tr.content)
result.push({ result.push({
role: 'tool', role: 'tool',
tool_call_id: tr.tool_use_id ?? 'unknown', tool_call_id: tr.tool_use_id ?? 'unknown',
content: tr.is_error ? `Error: ${trContent}` : trContent, content: convertToolResultContent(tr.content, tr.is_error),
}) })
} }
@@ -611,6 +641,7 @@ function repairPossiblyTruncatedObjectJson(raw: string): string | null {
async function* openaiStreamToAnthropic( async function* openaiStreamToAnthropic(
response: Response, response: Response,
model: string, model: string,
signal?: AbortSignal,
): AsyncGenerator<AnthropicStreamEvent> { ): AsyncGenerator<AnthropicStreamEvent> {
const messageId = makeMessageId() const messageId = makeMessageId()
let contentBlockIndex = 0 let contentBlockIndex = 0
@@ -658,6 +689,51 @@ async function* openaiStreamToAnthropic(
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
const STREAM_IDLE_TIMEOUT_MS = 120_000 // 2 minutes without data = connection likely dead
let lastDataTime = Date.now()
/**
* Read from the stream with an idle timeout. If no data arrives within
* STREAM_IDLE_TIMEOUT_MS, assume the connection is dead and throw so
* withRetry can reconnect. This prevents indefinite hangs on stale
* SSE connections from OpenAI/Gemini during long-running sessions.
* Respects the caller's AbortSignal — clears the idle timer on abort
* so the rejection reason is AbortError, not a spurious idle timeout.
*/
async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array>> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
const elapsed = Math.round((Date.now() - lastDataTime) / 1000)
reject(new Error(
`OpenAI/Gemini SSE stream idle for ${elapsed}s (limit: ${STREAM_IDLE_TIMEOUT_MS / 1000}s). Connection likely dropped.`,
))
}, STREAM_IDLE_TIMEOUT_MS)
// If the caller aborts, clear the timer so the AbortError surfaces
// cleanly instead of being masked by a spurious idle timeout.
let abortCleanup: (() => void) | undefined
if (signal) {
abortCleanup = () => {
clearTimeout(timeoutId)
}
signal.addEventListener('abort', abortCleanup, { once: true })
}
reader.read().then(
result => {
clearTimeout(timeoutId)
if (signal && abortCleanup) signal.removeEventListener('abort', abortCleanup)
if (result.value) lastDataTime = Date.now()
resolve(result)
},
err => {
clearTimeout(timeoutId)
if (signal && abortCleanup) signal.removeEventListener('abort', abortCleanup)
reject(err)
},
)
})
}
const closeActiveContentBlock = async function* () { const closeActiveContentBlock = async function* () {
if (!hasEmittedContentStart) return if (!hasEmittedContentStart) return
@@ -685,7 +761,7 @@ async function* openaiStreamToAnthropic(
try { try {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await readWithTimeout()
if (done) break if (done) break
buffer += decoder.decode(value, { stream: true }) buffer += decoder.decode(value, { stream: true })
@@ -1045,13 +1121,13 @@ class OpenAIShimMessages {
const isResponsesStream = response.url?.includes('/responses') const isResponsesStream = response.url?.includes('/responses')
return new OpenAIShimStream( return new OpenAIShimStream(
(request.transport === 'codex_responses' || isResponsesStream) (request.transport === 'codex_responses' || isResponsesStream)
? codexStreamToAnthropic(response, request.resolvedModel) ? codexStreamToAnthropic(response, request.resolvedModel, options?.signal)
: openaiStreamToAnthropic(response, request.resolvedModel), : openaiStreamToAnthropic(response, request.resolvedModel, options?.signal),
) )
} }
if (request.transport === 'codex_responses') { if (request.transport === 'codex_responses') {
const data = await collectCodexCompletedResponse(response) const data = await collectCodexCompletedResponse(response, options?.signal)
return convertCodexResponseToAnthropicMessage( return convertCodexResponseToAnthropicMessage(
data, data,
request.resolvedModel, request.resolvedModel,
@@ -1114,7 +1190,6 @@ class OpenAIShimMessages {
const githubEndpointType = getGithubEndpointType(request.baseUrl) const githubEndpointType = getGithubEndpointType(request.baseUrl)
const isGithubMode = isGithubModelsMode() const isGithubMode = isGithubModelsMode()
const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses' const isGithubWithCodexTransport = isGithubMode && request.transport === 'codex_responses'
const isGithubCopilotEndpoint = isGithubMode && githubEndpointType === 'copilot'
if (isGithubWithCodexTransport) { if (isGithubWithCodexTransport) {
const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? '' const apiKey = this.providerOverride?.apiKey ?? process.env.OPENAI_API_KEY ?? ''
@@ -1141,11 +1216,26 @@ class OpenAIShimMessages {
} }
if (request.transport === 'codex_responses' && !isGithubMode) { if (request.transport === 'codex_responses' && !isGithubMode) {
const credentials = resolveCodexApiCredentials() const refreshResult = await refreshCodexAccessTokenIfNeeded().catch(
async error => {
logForDebugging(
`[codex] access token refresh failed before request: ${error instanceof Error ? error.message : String(error)}`,
{ level: 'warn' },
)
return {
refreshed: false,
credentials: await readCodexCredentialsAsync(),
}
},
)
const credentials = resolveRuntimeCodexCredentials({
storedCredentials: refreshResult.credentials,
})
if (!credentials.apiKey) { if (!credentials.apiKey) {
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
const authHint = credentials.authPath const authHint = credentials.authPath
? ` or place a Codex auth.json at ${credentials.authPath}` ? `${oauthHint} or place a Codex auth.json at ${credentials.authPath}`
: '' : oauthHint
const safeModel = const safeModel =
redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ?? redactSecretValueForDisplay(request.requestedModel, process.env as SecretValueSource) ??
'the requested model' 'the requested model'
@@ -1155,7 +1245,7 @@ class OpenAIShimMessages {
} }
if (!credentials.accountId) { if (!credentials.accountId) {
throw new Error( throw new Error(
'Codex auth is missing chatgpt_account_id. Re-login with the Codex CLI or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.', 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, the Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.',
) )
} }
@@ -1216,18 +1306,20 @@ class OpenAIShimMessages {
const isGithub = isGithubModelsMode() const isGithub = isGithubModelsMode()
const isMistral = isMistralMode() const isMistral = isMistralMode()
const isLocal = isLocalProviderUrl(request.baseUrl)
const githubEndpointType = getGithubEndpointType(request.baseUrl) const githubEndpointType = getGithubEndpointType(request.baseUrl)
const isGithubCopilot = isGithub && githubEndpointType === 'copilot' const isGithubCopilot = isGithub && githubEndpointType === 'copilot'
const isGithubModels = isGithub && (githubEndpointType === 'models' || githubEndpointType === 'custom') const isGithubModels = isGithub && (githubEndpointType === 'models' || githubEndpointType === 'custom')
if ((isGithub || isMistral) && body.max_completion_tokens !== undefined) { if ((isGithub || isMistral || isLocal) && body.max_completion_tokens !== undefined) {
body.max_tokens = body.max_completion_tokens body.max_tokens = body.max_completion_tokens
delete body.max_completion_tokens delete body.max_completion_tokens
} }
// mistral also doesn't recognize body.store // mistral and gemini don't recognize body.store — Gemini returns 400
if (isMistral) { // "Invalid JSON payload received. Unknown name 'store': Cannot find field."
if (isMistral || isGeminiMode()) {
delete body.store delete body.store
} }

View File

@@ -0,0 +1,225 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as realOs from 'node:os'
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
.toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.signature`
}
describe('resolveCodexApiCredentials with secure storage', () => {
afterEach(() => {
mock.restore()
})
test('loads Codex credentials from OpenClaude secure storage', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
apiKey: 'codex-api-key-token',
accessToken: 'header.payload.signature',
accountId: 'acct_secure',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-secure-storage'
)
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('codex-api-key-token')
expect(credentials.accountId).toBe('acct_secure')
expect(credentials.source).toBe('secure-storage')
})
test('prefers explicit env credentials over secure storage', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
accessToken: 'stored-token',
accountId: 'acct_stored',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-env-precedence'
)
const credentials = resolveCodexApiCredentials({
CODEX_API_KEY: 'env-token',
CHATGPT_ACCOUNT_ID: 'acct_env',
} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('env-token')
expect(credentials.accountId).toBe('acct_env')
expect(credentials.source).toBe('env')
})
test('parses nested chatgpt_account_id from a CODEX_API_KEY JWT', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => undefined,
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-env-nested-account'
)
const credentials = resolveCodexApiCredentials({
CODEX_API_KEY: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_nested_env',
},
}),
} as NodeJS.ProcessEnv)
expect(credentials.accountId).toBe('acct_nested_env')
expect(credentials.source).toBe('env')
})
test('parses nested chatgpt_account_id from auth.json tokens', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => undefined,
}))
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-auth-'))
const authPath = join(tempDir, 'auth.json')
writeFileSync(
authPath,
JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_nested_auth_json',
},
}),
}),
'utf8',
)
try {
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-auth-json-nested-account'
)
const credentials = resolveCodexApiCredentials({
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv)
expect(credentials.accountId).toBe('acct_nested_auth_json')
expect(credentials.source).toBe('auth.json')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('does not read default auth.json when secure storage already has Codex credentials', async () => {
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => ({
apiKey: 'codex-api-key-token',
accessToken: 'header.payload.signature',
accountId: 'acct_secure',
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-secure-storage-no-auth-io'
)
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.apiKey).toBe('codex-api-key-token')
expect(credentials.accountId).toBe('acct_secure')
expect(credentials.source).toBe('secure-storage')
})
test('falls back to the default auth.json when stored Codex refresh is cooling down', async () => {
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
const authJson = JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_auth_json',
},
}),
})
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true })
writeFileSync(join(tempHomeDir, '.codex', 'auth.json'), authJson, 'utf8')
mock.module('node:os', () => ({
...realOs,
homedir: () => tempHomeDir,
}))
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => true,
readCodexCredentials: () => ({
accessToken: 'stored-token',
refreshToken: 'refresh-stored',
accountId: 'acct_stored',
lastRefreshFailureAt: Date.now(),
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-refresh-cooldown-fallback'
)
try {
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.source).toBe('auth.json')
expect(credentials.accountId).toBe('acct_auth_json')
expect(credentials.apiKey).not.toBe('stored-token')
} finally {
rmSync(tempHomeDir, { force: true, recursive: true })
}
})
test('preserves the stored account id when auth.json fallback lacks one', async () => {
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
const authJson = JSON.stringify({
openai_api_key: 'auth-json-access-token',
})
mkdirSync(join(tempHomeDir, '.codex'), { recursive: true })
writeFileSync(join(tempHomeDir, '.codex', 'auth.json'), authJson, 'utf8')
mock.module('node:os', () => ({
...realOs,
homedir: () => tempHomeDir,
}))
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => true,
readCodexCredentials: () => ({
accessToken: 'stored-token',
refreshToken: 'refresh-stored',
accountId: 'acct_stored',
lastRefreshFailureAt: Date.now(),
}),
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveCodexApiCredentials } = await import(
'./providerConfig.js?codex-refresh-cooldown-account-id-fallback'
)
try {
const credentials = resolveCodexApiCredentials({} as NodeJS.ProcessEnv)
expect(credentials.source).toBe('auth.json')
expect(credentials.apiKey).toBe('auth-json-access-token')
expect(credentials.accountId).toBe('acct_stored')
} finally {
rmSync(tempHomeDir, { force: true, recursive: true })
}
})
})

View File

@@ -0,0 +1,107 @@
import { afterEach, expect, mock, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { resolveRuntimeCodexCredentials } from './providerConfig.js'
afterEach(() => {
mock.restore()
})
function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
.toString('base64url')
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `${header}.${body}.signature`
}
test('runtime credential resolution honors explicit auth.json over stored secure-storage tokens', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-explicit-auth-'))
const authPath = join(tempDir, 'auth.json')
writeFileSync(
authPath,
JSON.stringify({
openai_api_key: makeJwt({
'https://api.openai.com/auth': {
chatgpt_account_id: 'acct_explicit_auth_json',
},
}),
}),
'utf8',
)
try {
const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv,
storedCredentials: {
apiKey: 'stored-api-key',
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(credentials.source).toBe('auth.json')
expect(credentials.accountId).toBe('acct_explicit_auth_json')
expect(credentials.apiKey).not.toBe('stored-api-key')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('runtime credential resolution preserves an explicit auth.json path even when it is missing', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-missing-auth-'))
const authPath = join(tempDir, 'missing-auth.json')
try {
const credentials = resolveRuntimeCodexCredentials({
env: {
CODEX_AUTH_JSON_PATH: authPath,
} as NodeJS.ProcessEnv,
storedCredentials: {
apiKey: 'stored-api-key',
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(credentials.source).toBe('none')
expect(credentials.authPath).toBe(authPath)
expect(credentials.apiKey).toBe('')
} finally {
rmSync(tempDir, { force: true, recursive: true })
}
})
test('runtime credential resolution avoids sync secure-storage reads when async credentials are provided', async () => {
let syncReadCalled = false
mock.module('../../utils/codexCredentials.js', () => ({
isCodexRefreshFailureCoolingDown: () => false,
readCodexCredentials: () => {
syncReadCalled = true
throw new Error('sync secure-storage read should not run in runtime resolution')
},
}))
// @ts-expect-error cache-busting query string for Bun module mocks
const { resolveRuntimeCodexCredentials } = await import(
'./providerConfig.js?runtime-no-sync-secure-storage'
)
const credentials = resolveRuntimeCodexCredentials({
env: {} as NodeJS.ProcessEnv,
storedCredentials: {
accessToken: 'stored-access-token',
accountId: 'acct_stored',
},
})
expect(syncReadCalled).toBe(false)
expect(credentials.source).toBe('secure-storage')
expect(credentials.apiKey).toBe('stored-access-token')
expect(credentials.accountId).toBe('acct_stored')
})

View File

@@ -3,7 +3,16 @@ import { isIP } from 'node:net'
import { homedir } from 'node:os' import { homedir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import {
isCodexRefreshFailureCoolingDown,
readCodexCredentials,
type CodexCredentialBlob,
} from '../../utils/codexCredentials.js'
import { isEnvTruthy } from '../../utils/envUtils.js' import { isEnvTruthy } from '../../utils/envUtils.js'
import {
asTrimmedString,
parseChatgptAccountId,
} from './codexOAuthShared.js'
export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' export const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex' export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'
@@ -60,6 +69,8 @@ const CODEX_ALIAS_MODELS: Record<
type CodexAlias = keyof typeof CODEX_ALIAS_MODELS type CodexAlias = keyof typeof CODEX_ALIAS_MODELS
type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'
const OPENAI_CODEX_SHORTCUT_ALIASES = new Set(['codexplan', 'codexspark'])
export type ProviderTransport = 'chat_completions' | 'codex_responses' export type ProviderTransport = 'chat_completions' | 'codex_responses'
export type ResolvedProviderRequest = { export type ResolvedProviderRequest = {
@@ -76,7 +87,7 @@ export type ResolvedCodexCredentials = {
apiKey: string apiKey: string
accountId?: string accountId?: string
authPath?: string authPath?: string
source: 'env' | 'auth.json' | 'none' source: 'env' | 'secure-storage' | 'auth.json' | 'none'
} }
type ModelDescriptor = { type ModelDescriptor = {
@@ -112,12 +123,6 @@ function isPrivateIpv6Address(hostname: string): boolean {
return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80 return (prefix & 0xfe00) === 0xfc00 || (prefix & 0xffc0) === 0xfe80
} }
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed ? trimmed : undefined
}
// Reads an env-var-style string intended as a URL or path, rejecting both // Reads an env-var-style string intended as a URL or path, rejecting both
// empty strings and the literal string "undefined" that Windows shells can // empty strings and the literal string "undefined" that Windows shells can
// write when a variable is unset-then-referenced without quotes (issue #336). // write when a variable is unset-then-referenced without quotes (issue #336).
@@ -149,23 +154,6 @@ function readNestedString(
return undefined return undefined
} }
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
const parts = token.split('.')
if (parts.length < 2) return undefined
try {
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const json = Buffer.from(padded, 'base64').toString('utf8')
const parsed = JSON.parse(json)
return parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: undefined
} catch {
return undefined
}
}
function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined { function parseReasoningEffort(value: string | undefined): ReasoningEffort | undefined {
if (!value) return undefined if (!value) return undefined
const normalized = value.trim().toLowerCase() const normalized = value.trim().toLowerCase()
@@ -220,6 +208,12 @@ export function isCodexAlias(model: string): boolean {
return base in CODEX_ALIAS_MODELS return base in CODEX_ALIAS_MODELS
} }
function isOpenAICodexShortcutAlias(model: string): boolean {
const normalized = model.trim().toLowerCase()
const base = normalized.split('?', 1)[0] ?? normalized
return OPENAI_CODEX_SHORTCUT_ALIASES.has(base)
}
export function shouldUseCodexTransport( export function shouldUseCodexTransport(
model: string, model: string,
baseUrl: string | undefined, baseUrl: string | undefined,
@@ -367,13 +361,41 @@ export function resolveProviderRequest(options?: {
options?.fallbackModel?.trim() || options?.fallbackModel?.trim() ||
(isGithubMode ? 'github:copilot' : 'gpt-4o') (isGithubMode ? 'github:copilot' : 'gpt-4o')
const descriptor = parseModelDescriptor(requestedModel) const descriptor = parseModelDescriptor(requestedModel)
const rawBaseUrl = const explicitBaseUrl = asEnvUrl(options?.baseUrl)
asEnvUrl(options?.baseUrl) ?? const envBaseUrlRaw =
explicitBaseUrl ??
asEnvUrl( asEnvUrl(
isMistralMode ? (process.env.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL) : process.env.OPENAI_BASE_URL, isMistralMode
? (process.env.MISTRAL_BASE_URL ?? DEFAULT_MISTRAL_BASE_URL)
: process.env.OPENAI_BASE_URL
) ?? ) ??
asEnvUrl(process.env.OPENAI_API_BASE) asEnvUrl(process.env.OPENAI_API_BASE)
const isCodexModelForGithub = isGithubMode && isCodexAlias(requestedModel)
const envBaseUrl =
isCodexModelForGithub && envBaseUrlRaw && getGithubEndpointType(envBaseUrlRaw) === 'custom'
? undefined
: envBaseUrlRaw
const rawBaseUrl = explicitBaseUrl ?? envBaseUrl
const shellModel = process.env.OPENAI_MODEL?.trim() ?? ''
const envIsCodexShortcut = isOpenAICodexShortcutAlias(shellModel)
const envResolvedCodexModel = envIsCodexShortcut
? parseModelDescriptor(shellModel).baseModel
: null
const requestedMatchesEnvCodexShortcut =
Boolean(options?.model) &&
Boolean(envResolvedCodexModel) &&
descriptor.baseModel === envResolvedCodexModel
const isCodexAliasModel =
isOpenAICodexShortcutAlias(requestedModel) || requestedMatchesEnvCodexShortcut
const hasUserSetBaseUrl = rawBaseUrl && rawBaseUrl !== DEFAULT_OPENAI_BASE_URL
const finalBaseUrl =
!isGithubMode && isCodexAliasModel && !hasUserSetBaseUrl
? DEFAULT_CODEX_BASE_URL
: rawBaseUrl
const githubEndpointType = isGithubMode const githubEndpointType = isGithubMode
? getGithubEndpointType(rawBaseUrl) ? getGithubEndpointType(rawBaseUrl)
: 'custom' : 'custom'
@@ -386,7 +408,7 @@ export function resolveProviderRequest(options?: {
: requestedModel : requestedModel
const transport: ProviderTransport = const transport: ProviderTransport =
shouldUseCodexTransport(requestedModel, rawBaseUrl) || shouldUseCodexTransport(requestedModel, finalBaseUrl) ||
(isGithubCopilot && shouldUseGithubResponsesApi(githubResolvedModel)) (isGithubCopilot && shouldUseGithubResponsesApi(githubResolvedModel))
? 'codex_responses' ? 'codex_responses'
: 'chat_completions' : 'chat_completions'
@@ -410,7 +432,7 @@ export function resolveProviderRequest(options?: {
requestedModel, requestedModel,
resolvedModel, resolvedModel,
baseUrl: baseUrl:
(rawBaseUrl ?? (finalBaseUrl ??
(isGithubCopilot && transport === 'codex_responses' (isGithubCopilot && transport === 'codex_responses'
? GITHUB_COPILOT_BASE_URL ? GITHUB_COPILOT_BASE_URL
: (isGithubMode : (isGithubMode
@@ -458,18 +480,6 @@ export function resolveCodexAuthPath(
return join(homedir(), '.codex', 'auth.json') return join(homedir(), '.codex', 'auth.json')
} }
export function parseChatgptAccountId(
token: string | undefined,
): string | undefined {
if (!token) return undefined
const payload = decodeJwtPayload(token)
const fromClaim = asTrimmedString(
payload?.['https://api.openai.com/auth.chatgpt_account_id'],
)
if (fromClaim) return fromClaim
return asTrimmedString(payload?.chatgpt_account_id)
}
function loadCodexAuthJson( function loadCodexAuthJson(
authPath: string, authPath: string,
): Record<string, unknown> | undefined { ): Record<string, unknown> | undefined {
@@ -485,8 +495,97 @@ function loadCodexAuthJson(
} }
} }
export function resolveCodexApiCredentials( function resolveCodexAuthJsonCredentials(options: {
env: NodeJS.ProcessEnv = process.env, authJson: Record<string, unknown> | undefined
authPath: string
envAccountId?: string
missingSource?: ResolvedCodexCredentials['source']
}): ResolvedCodexCredentials {
const { authJson, authPath, envAccountId } = options
if (!authJson) {
return {
apiKey: '',
authPath,
source: options.missingSource ?? 'none',
}
}
const apiKey = readNestedString(authJson, [
['openai_api_key'],
['openaiApiKey'],
['access_token'],
['accessToken'],
['tokens', 'access_token'],
['tokens', 'accessToken'],
['auth', 'access_token'],
['auth', 'accessToken'],
['token', 'access_token'],
['token', 'accessToken'],
])
// OIDC identity tokens can carry the ChatGPT account id, but they are not
// valid bearer credentials for Codex API requests.
const idToken = readNestedString(authJson, [
['id_token'],
['idToken'],
['tokens', 'id_token'],
['tokens', 'idToken'],
])
const accountId =
envAccountId ??
readNestedString(authJson, [
['account_id'],
['accountId'],
['tokens', 'account_id'],
['tokens', 'accountId'],
['auth', 'account_id'],
['auth', 'accountId'],
]) ??
parseChatgptAccountId(apiKey) ??
parseChatgptAccountId(idToken)
if (!apiKey) {
return {
apiKey: '',
accountId,
authPath,
source: options.missingSource ?? 'none',
}
}
return {
apiKey,
accountId,
authPath,
source: 'auth.json',
}
}
export function resolveStoredCodexCredentials(options: {
storedCredentials: Pick<
CodexCredentialBlob,
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
>
envAccountId?: string
}): ResolvedCodexCredentials {
const { storedCredentials, envAccountId } = options
return {
apiKey: storedCredentials.apiKey ?? storedCredentials.accessToken,
accountId:
envAccountId ??
storedCredentials.accountId ??
parseChatgptAccountId(storedCredentials.idToken) ??
parseChatgptAccountId(storedCredentials.accessToken),
source: 'secure-storage',
}
}
function resolveEnvOrAuthJsonCodexCredentials(
env: NodeJS.ProcessEnv,
options?: {
explicitAuthPathOnly?: boolean
},
): ResolvedCodexCredentials { ): ResolvedCodexCredentials {
const envApiKey = asTrimmedString(env.CODEX_API_KEY) const envApiKey = asTrimmedString(env.CODEX_API_KEY)
const envAccountId = const envAccountId =
@@ -501,55 +600,127 @@ export function resolveCodexApiCredentials(
} }
} }
const explicitAuthPathConfigured = Boolean(
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
)
if (!explicitAuthPathConfigured && options?.explicitAuthPathOnly) {
return {
apiKey: '',
accountId: envAccountId,
source: 'none',
}
}
const authPath = resolveCodexAuthPath(env) const authPath = resolveCodexAuthPath(env)
const authJson = loadCodexAuthJson(authPath) const authJson = loadCodexAuthJson(authPath)
if (!authJson) { return resolveCodexAuthJsonCredentials({
return { authJson,
apiKey: '',
authPath, authPath,
source: 'none', envAccountId,
})
}
export function resolveRuntimeCodexCredentials(options?: {
env?: NodeJS.ProcessEnv
storedCredentials?: Pick<
CodexCredentialBlob,
'apiKey' | 'accessToken' | 'idToken' | 'accountId'
>
}): ResolvedCodexCredentials {
const env = options?.env ?? process.env
const explicitCredentials = resolveEnvOrAuthJsonCodexCredentials(env, {
explicitAuthPathOnly: true,
})
const explicitAuthPathConfigured = Boolean(
asTrimmedString(env.CODEX_AUTH_JSON_PATH) ?? asTrimmedString(env.CODEX_HOME),
)
const hasStoredCredentialsOption = Boolean(
options &&
Object.prototype.hasOwnProperty.call(options, 'storedCredentials'),
)
if (
explicitAuthPathConfigured ||
explicitCredentials.source === 'env' ||
explicitCredentials.source === 'auth.json'
) {
return explicitCredentials
}
if (options?.storedCredentials?.accessToken) {
return resolveStoredCodexCredentials({
storedCredentials: options.storedCredentials,
envAccountId:
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
asTrimmedString(env.CHATGPT_ACCOUNT_ID),
})
}
if (hasStoredCredentialsOption) {
return resolveEnvOrAuthJsonCodexCredentials(env)
}
return resolveCodexApiCredentials(env)
}
export function resolveCodexApiCredentials(
env: NodeJS.ProcessEnv = process.env,
): ResolvedCodexCredentials {
const envAccountId =
asTrimmedString(env.CODEX_ACCOUNT_ID) ??
asTrimmedString(env.CHATGPT_ACCOUNT_ID)
const envOrExplicitAuthJsonCredentials = resolveEnvOrAuthJsonCodexCredentials(
env,
{
explicitAuthPathOnly: true,
},
)
if (
envOrExplicitAuthJsonCredentials.source === 'env' ||
envOrExplicitAuthJsonCredentials.source === 'auth.json' ||
envOrExplicitAuthJsonCredentials.authPath
) {
return envOrExplicitAuthJsonCredentials
}
const storedCredentials = readCodexCredentials()
if (storedCredentials?.accessToken) {
const resolvedStoredCredentials = resolveStoredCodexCredentials({
storedCredentials,
envAccountId,
})
const shouldCheckDefaultAuthJson =
!resolvedStoredCredentials.accountId ||
isCodexRefreshFailureCoolingDown(storedCredentials)
if (!shouldCheckDefaultAuthJson) {
return resolvedStoredCredentials
}
const authPath = resolveCodexAuthPath(env)
const authJson = loadCodexAuthJson(authPath)
const resolvedAuthJsonCredentials = resolveCodexAuthJsonCredentials({
authJson,
authPath,
envAccountId,
})
if (resolvedAuthJsonCredentials.apiKey) {
return {
...resolvedAuthJsonCredentials,
accountId:
resolvedAuthJsonCredentials.accountId ??
resolvedStoredCredentials.accountId,
} }
} }
const apiKey = readNestedString(authJson, [ return resolvedStoredCredentials
['access_token'],
['accessToken'],
['tokens', 'access_token'],
['tokens', 'accessToken'],
['auth', 'access_token'],
['auth', 'accessToken'],
['token', 'access_token'],
['token', 'accessToken'],
['tokens', 'id_token'],
['tokens', 'idToken'],
])
const accountId =
envAccountId ??
readNestedString(authJson, [
['account_id'],
['accountId'],
['tokens', 'account_id'],
['tokens', 'accountId'],
['auth', 'account_id'],
['auth', 'accountId'],
]) ??
parseChatgptAccountId(apiKey)
if (!apiKey) {
return {
apiKey: '',
accountId,
authPath,
source: 'none',
}
} }
return { return resolveEnvOrAuthJsonCodexCredentials(env)
apiKey,
accountId,
authPath,
source: 'auth.json',
}
} }
export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined { export function getReasoningEffortForModel(model: string): ReasoningEffort | undefined {
@@ -559,3 +730,18 @@ export function getReasoningEffortForModel(model: string): ReasoningEffort | und
const aliasConfig = CODEX_ALIAS_MODELS[alias] const aliasConfig = CODEX_ALIAS_MODELS[alias]
return aliasConfig?.reasoningEffort return aliasConfig?.reasoningEffort
} }
export function supportsCodexReasoningEffort(model: string): boolean {
const normalized = model.trim().toLowerCase()
const base = normalized.split('?', 1)[0] ?? normalized
if (base === 'gpt-5.3-codex-spark' || base === 'codexspark') {
return false
}
if (getReasoningEffortForModel(base) !== undefined) {
return true
}
return /^gpt-5(?:[.-]|$)/.test(base)
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test'
import {
getEffectiveContextWindowSize,
getAutoCompactThreshold,
} from './autoCompact.ts'
describe('getEffectiveContextWindowSize', () => {
test('returns positive value for known models with large context windows', () => {
// claude-sonnet-4 has 200k context
const effective = getEffectiveContextWindowSize('claude-sonnet-4')
expect(effective).toBeGreaterThan(0)
})
test('never returns negative even for unknown 3P models (issue #635)', () => {
// Previously, unknown 3P models got 8k context → effective context was
// 8k minus 20k summary reservation = -12k, causing infinite auto-compact.
// Now the fallback is 128k and there's a floor, so effective is always
// at least reservedTokensForSummary + buffer.
process.env.CLAUDE_CODE_USE_OPENAI = '1'
try {
const effective = getEffectiveContextWindowSize('some-unknown-3p-model')
expect(effective).toBeGreaterThan(0)
// Must be at least summary reservation (20k) + buffer (13k) = 33k
expect(effective).toBeGreaterThanOrEqual(33_000)
} finally {
delete process.env.CLAUDE_CODE_USE_OPENAI
}
})
})
describe('getAutoCompactThreshold', () => {
test('returns positive threshold for known models', () => {
const threshold = getAutoCompactThreshold('claude-sonnet-4')
expect(threshold).toBeGreaterThan(0)
})
test('never returns negative threshold even for unknown 3P models (issue #635)', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
try {
const threshold = getAutoCompactThreshold('some-unknown-3p-model')
expect(threshold).toBeGreaterThan(0)
} finally {
delete process.env.CLAUDE_CODE_USE_OPENAI
}
})
})

View File

@@ -45,7 +45,12 @@ export function getEffectiveContextWindowSize(model: string): number {
} }
} }
return contextWindow - reservedTokensForSummary // Floor: effective context must be at least the summary reservation plus a
// usable buffer. If it goes lower, the auto-compact threshold becomes
// negative and fires on every message (issue #635).
const autocompactBuffer = 13_000 // must match AUTOCOMPACT_BUFFER_TOKENS
const effectiveContext = contextWindow - reservedTokensForSummary
return Math.max(effectiveContext, reservedTokensForSummary + autocompactBuffer)
} }
export type AutoCompactTrackingState = { export type AutoCompactTrackingState = {

View File

@@ -206,9 +206,12 @@ export function isMcpSessionExpiredError(error: Error): boolean {
} }
/** /**
* Default timeout for MCP tool calls (effectively infinite - ~27.8 hours). * Default timeout for MCP tool calls (5 minutes — reasonable for most tools).
* Use MCP_TOOL_TIMEOUT env var to override per-server.
* The previous default of ~27.8 hours effectively meant no timeout, causing
* tools to hang indefinitely on unresponsive servers.
*/ */
const DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000 const DEFAULT_MCP_TOOL_TIMEOUT_MS = 300_000
/** /**
* Cap on MCP tool descriptions and server instructions sent to the model. * Cap on MCP tool descriptions and server instructions sent to the model.
@@ -1764,10 +1767,32 @@ export const fetchToolsForClient = memoizeWithLRU(
return [] return []
} }
const result = (await client.client.request( // Retry tool list fetch up to 2 times on transient failures.
// Without retry, a single timeout during tools/list makes all MCP tools
// silently disappear from the model's context until the next reconnect.
let result: ListToolsResult | undefined
let lastError: unknown
for (let attempt = 0; attempt < 3; attempt++) {
try {
result = (await client.client.request(
{ method: 'tools/list' }, { method: 'tools/list' },
ListToolsResultSchema, ListToolsResultSchema,
)) as ListToolsResult )) as ListToolsResult
break
} catch (err) {
lastError = err
if (attempt < 2) {
logMCPDebug(
client.name,
`tools/list failed (attempt ${attempt + 1}/3): ${errorMessage(err)}. Retrying...`,
)
await sleep(1000 * (attempt + 1))
}
}
}
if (!result) {
throw lastError ?? new Error('tools/list failed after 3 attempts')
}
// Sanitize tool data from MCP server // Sanitize tool data from MCP server
const toolsToProcess = recursivelySanitizeUnicode(result.tools) const toolsToProcess = recursivelySanitizeUnicode(result.tools)
@@ -2864,6 +2889,11 @@ export async function callMCPToolWithUrlElicitationRetry({
}): Promise<MCPToolCallResult> { }): Promise<MCPToolCallResult> {
const MAX_URL_ELICITATION_RETRIES = 3 const MAX_URL_ELICITATION_RETRIES = 3
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
// Check abort signal before each attempt — without this, a cancelled
// elicitation retry loop continues spinning until MAX retries
if (signal.aborted) {
throw new Error('Tool call aborted during URL elicitation')
}
try { try {
return await callToolFn({ return await callToolFn({
client: connectedClient, client: connectedClient,
@@ -3156,9 +3186,12 @@ async function callMCPTool({
errorDetails = String(result.error) errorDetails = String(result.error)
} }
logMCPError(name, errorDetails) logMCPError(name, errorDetails)
// Include server and tool name in telemetry for debugging, but keep
// the human-readable message unchanged to avoid breaking error consumers
// that parse the message string.
throw new McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( throw new McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
errorDetails, errorDetails,
'MCP tool returned error', `MCP tool [${name}] ${tool}: ${errorDetails}`,
'_meta' in result && result._meta ? { _meta: result._meta } : undefined, '_meta' in result && result._meta ? { _meta: result._meta } : undefined,
) )
} }

View File

@@ -0,0 +1,155 @@
import { afterEach, expect, mock, test } from 'bun:test'
afterEach(() => {
mock.restore()
})
test('custom error responses log the error redirect analytics event', async () => {
const events: Array<{
name: string
metadata: Record<string, boolean | number | undefined>
}> = []
mock.module('src/services/analytics/index.js', () => ({
logEvent: (
name: string,
metadata: Record<string, boolean | number | undefined>,
) => {
events.push({ name, metadata })
},
}))
const { AuthCodeListener } = await import(
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
)
const listener = new AuthCodeListener('/callback')
const response = {
writeHead: () => {},
end: () => {},
}
;(listener as any).pendingResponse = response
listener.handleErrorRedirect(res => {
res.writeHead(400, {
'Content-Type': 'text/plain; charset=utf-8',
})
res.end('cancelled')
})
expect(events).toEqual([
{
name: 'tengu_oauth_automatic_redirect_error',
metadata: { custom_handler: true },
},
])
})
test('custom handlers that do not end the response are closed automatically and still log analytics', async () => {
const events: Array<{
name: string
metadata: Record<string, boolean | number | undefined>
}> = []
const response = {
destroyed: false,
headersSent: false,
writableEnded: false,
writeHead: () => {
response.headersSent = true
},
end: () => {
response.writableEnded = true
},
}
mock.module('src/services/analytics/index.js', () => ({
logEvent: (
name: string,
metadata: Record<string, boolean | number | undefined>,
) => {
events.push({ name, metadata })
},
}))
mock.module('../../utils/log.js', () => ({
logError: () => {},
}))
const { AuthCodeListener } = await import(
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
)
const listener = new AuthCodeListener('/callback')
;(listener as any).pendingResponse = response
listener.handleErrorRedirect(res => {
res.writeHead(400, {
'Content-Type': 'text/plain; charset=utf-8',
})
})
expect(response.writableEnded).toBe(true)
expect((listener as any).pendingResponse).toBeNull()
expect(events).toEqual([
{
name: 'tengu_oauth_automatic_redirect_error',
metadata: { custom_handler: true },
},
])
})
test('custom handlers that throw are logged, converted to a fallback response, and do not log analytics', async () => {
const events: Array<{
name: string
metadata: Record<string, boolean | number | undefined>
}> = []
const loggedErrors: unknown[] = []
const response = {
destroyed: false,
headersSent: false,
writableEnded: false,
statusCode: 0,
body: '',
writeHead: (statusCode: number) => {
response.headersSent = true
response.statusCode = statusCode
},
end: (body = '') => {
response.writableEnded = true
response.body = body
},
}
mock.module('src/services/analytics/index.js', () => ({
logEvent: (
name: string,
metadata: Record<string, boolean | number | undefined>,
) => {
events.push({ name, metadata })
},
}))
mock.module('../../utils/log.js', () => ({
logError: (error: unknown) => {
loggedErrors.push(error)
},
}))
const { AuthCodeListener } = await import(
`./auth-code-listener.js?ts=${Date.now()}-${Math.random()}`
)
const listener = new AuthCodeListener('/callback')
;(listener as any).pendingResponse = response
listener.handleErrorRedirect(() => {
throw new Error('handler exploded')
})
expect(response.statusCode).toBe(500)
expect(response.body).toBe('Authentication redirect failed')
expect(response.writableEnded).toBe(true)
expect((listener as any).pendingResponse).toBeNull()
expect(loggedErrors).toHaveLength(1)
expect(events).toEqual([])
})

View File

@@ -0,0 +1,31 @@
import { afterEach, expect, test } from 'bun:test'
import { AuthCodeListener } from './auth-code-listener.js'
const listeners: AuthCodeListener[] = []
afterEach(() => {
while (listeners.length > 0) {
listeners.pop()?.close()
}
})
test('cancelPendingAuthorization rejects a pending OAuth wait', async () => {
const listener = new AuthCodeListener('/callback')
listeners.push(listener)
await listener.start()
const pendingAuthorization = listener.waitForAuthorization(
'state-test',
async () => {},
)
listener.cancelPendingAuthorization(
new Error('Codex OAuth flow was cancelled.'),
)
await expect(pendingAuthorization).rejects.toThrow(
'Codex OAuth flow was cancelled.',
)
})

View File

@@ -71,6 +71,42 @@ export class AuthCodeListener {
}) })
} }
private respondToPendingRequest(options: {
handler: (res: ServerResponse) => void
analyticsEvent:
| 'tengu_oauth_automatic_redirect'
| 'tengu_oauth_automatic_redirect_error'
analyticsMetadata?: Record<string, boolean>
}): void {
if (!this.pendingResponse) return
const response = this.pendingResponse
try {
options.handler(response)
if (!response.writableEnded && !response.destroyed) {
response.end()
}
logEvent(options.analyticsEvent, options.analyticsMetadata ?? {})
} catch (error) {
logError(error)
if (!response.headersSent && !response.destroyed) {
response.writeHead(500, {
'Content-Type': 'text/plain; charset=utf-8',
})
}
if (!response.writableEnded && !response.destroyed) {
response.end('Authentication redirect failed')
}
} finally {
if (this.pendingResponse === response) {
this.pendingResponse = null
}
}
}
/** /**
* Completes the OAuth flow by redirecting the user's browser to a success page. * Completes the OAuth flow by redirecting the user's browser to a success page.
* Different success pages are shown based on the granted scopes. * Different success pages are shown based on the granted scopes.
@@ -85,9 +121,13 @@ export class AuthCodeListener {
// If custom handler provided, use it instead of default redirect // If custom handler provided, use it instead of default redirect
if (customHandler) { if (customHandler) {
customHandler(this.pendingResponse, scopes) this.respondToPendingRequest({
this.pendingResponse = null handler: res => {
logEvent('tengu_oauth_automatic_redirect', { custom_handler: true }) customHandler(res, scopes)
},
analyticsEvent: 'tengu_oauth_automatic_redirect',
analyticsMetadata: { custom_handler: true },
})
return return
} }
@@ -97,29 +137,48 @@ export class AuthCodeListener {
: getOauthConfig().CONSOLE_SUCCESS_URL : getOauthConfig().CONSOLE_SUCCESS_URL
// Send browser to success page // Send browser to success page
this.pendingResponse.writeHead(302, { Location: successUrl }) this.respondToPendingRequest({
this.pendingResponse.end() handler: res => {
this.pendingResponse = null res.writeHead(302, { Location: successUrl })
res.end()
logEvent('tengu_oauth_automatic_redirect', {}) },
analyticsEvent: 'tengu_oauth_automatic_redirect',
})
} }
/** /**
* Handles error case by sending a redirect to the appropriate success page with an error indicator, * Handles error case by sending a redirect to the appropriate success page with an error indicator,
* ensuring the browser flow is completed properly. * ensuring the browser flow is completed properly.
*/ */
handleErrorRedirect(): void { handleErrorRedirect(customHandler?: (res: ServerResponse) => void): void {
if (!this.pendingResponse) return if (!this.pendingResponse) return
if (customHandler) {
this.respondToPendingRequest({
handler: customHandler,
analyticsEvent: 'tengu_oauth_automatic_redirect_error',
analyticsMetadata: { custom_handler: true },
})
return
}
// TODO: swap to a different url once we have an error page // TODO: swap to a different url once we have an error page
const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL
// Send browser to error page this.respondToPendingRequest({
this.pendingResponse.writeHead(302, { Location: errorUrl }) handler: res => {
this.pendingResponse.end() res.writeHead(302, { Location: errorUrl })
this.pendingResponse = null res.end()
},
analyticsEvent: 'tengu_oauth_automatic_redirect_error',
})
}
logEvent('tengu_oauth_automatic_redirect_error', {}) cancelPendingAuthorization(
error: Error = new Error('OAuth authorization was cancelled.'),
): void {
this.reject(error)
this.close()
} }
private startLocalListener(onReady: () => Promise<void>): void { private startLocalListener(onReady: () => Promise<void>): void {
@@ -176,8 +235,7 @@ export class AuthCodeListener {
private handleError(err: Error): void { private handleError(err: Error): void {
logError(err) logError(err)
this.close() this.cancelPendingAuthorization(err)
this.reject(err)
} }
private resolve(authorizationCode: string): void { private resolve(authorizationCode: string): void {
@@ -185,6 +243,7 @@ export class AuthCodeListener {
this.promiseResolver(authorizationCode) this.promiseResolver(authorizationCode)
this.promiseResolver = null this.promiseResolver = null
this.promiseRejecter = null this.promiseRejecter = null
this.expectedState = null
} }
} }
@@ -193,6 +252,7 @@ export class AuthCodeListener {
this.promiseRejecter(error) this.promiseRejecter(error)
this.promiseResolver = null this.promiseResolver = null
this.promiseRejecter = null this.promiseRejecter = null
this.expectedState = null
} }
} }
@@ -207,5 +267,8 @@ export class AuthCodeListener {
this.localServer.removeAllListeners() this.localServer.removeAllListeners()
this.localServer.close() this.localServer.close()
} }
this.expectedState = null
this.port = 0
} }
} }

View File

@@ -109,7 +109,6 @@ const externalTips: Tip[] = [
`Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`, `Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`,
cooldownSessions: 5, cooldownSessions: 5,
isRelevant: async () => { isRelevant: async () => {
if (process.env.USER_TYPE === 'ant') return false
const config = getGlobalConfig() const config = getGlobalConfig()
// Show to users who haven't used plan mode recently (7+ days) // Show to users who haven't used plan mode recently (7+ days)
const daysSinceLastUse = config.lastPlanModeUse const daysSinceLastUse = config.lastPlanModeUse
@@ -401,9 +400,7 @@ const externalTips: Tip[] = [
{ {
id: 'shift-tab', id: 'shift-tab',
content: async () => content: async () =>
process.env.USER_TYPE === 'ant' `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`,
? `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode and auto mode`
: `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`,
cooldownSessions: 10, cooldownSessions: 10,
isRelevant: async () => true, isRelevant: async () => true,
}, },
@@ -476,7 +473,6 @@ const externalTips: Tip[] = [
`Your default model setting is Opus Plan Mode. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to activate Plan Mode and plan with Claude Opus.`, `Your default model setting is Opus Plan Mode. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to activate Plan Mode and plan with Claude Opus.`,
cooldownSessions: 2, cooldownSessions: 2,
async isRelevant() { async isRelevant() {
if (process.env.USER_TYPE === 'ant') return false
const config = getGlobalConfig() const config = getGlobalConfig()
const modelSetting = getUserSpecifiedModelSetting() const modelSetting = getUserSpecifiedModelSetting()
const hasOpusPlanMode = modelSetting === 'opusplan' const hasOpusPlanMode = modelSetting === 'opusplan'
@@ -624,33 +620,12 @@ const externalTips: Tip[] = [
content: async () => 'Use /feedback to help us improve!', content: async () => 'Use /feedback to help us improve!',
cooldownSessions: 15, cooldownSessions: 15,
async isRelevant() { async isRelevant() {
if (process.env.USER_TYPE === 'ant') {
return false
}
const config = getGlobalConfig() const config = getGlobalConfig()
return config.numStartups > 5 return config.numStartups > 5
}, },
}, },
] ]
const internalOnlyTips: Tip[] = const internalOnlyTips: Tip[] = []
process.env.USER_TYPE === 'ant'
? [
{
id: 'important-claudemd',
content: async () =>
'[internal] Use "IMPORTANT:" prefix for must-follow CLAUDE.md rules',
cooldownSessions: 30,
isRelevant: async () => true,
},
{
id: 'skillify',
content: async () =>
'[internal] Use /skillify to turn repeatable recurring workflows into reusable project skills',
cooldownSessions: 15,
isRelevant: async () => true,
},
]
: []
function getCustomTips(): Tip[] { function getCustomTips(): Tip[] {
const settings = getInitialSettings() const settings = getInitialSettings()

View File

@@ -4,6 +4,7 @@ import { registerBatchSkill } from './batch.js'
import { registerClaudeInChromeSkill } from './claudeInChrome.js' import { registerClaudeInChromeSkill } from './claudeInChrome.js'
import { registerDebugSkill } from './debug.js' import { registerDebugSkill } from './debug.js'
import { registerKeybindingsSkill } from './keybindings.js' import { registerKeybindingsSkill } from './keybindings.js'
import { registerLoopSkill } from './loop.js'
import { registerSimplifySkill } from './simplify.js' import { registerSimplifySkill } from './simplify.js'
import { registerUpdateConfigSkill } from './updateConfig.js' import { registerUpdateConfigSkill } from './updateConfig.js'
@@ -34,15 +35,10 @@ export function initBundledSkills(): void {
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
registerHunterSkill() registerHunterSkill()
} }
if (feature('AGENT_TRIGGERS')) { // /loop's isEnabled delegates to isKairosCronEnabled() — registered
/* eslint-disable @typescript-eslint/no-require-imports */ // unconditionally so the static import is bundled; visibility is gated
const { registerLoopSkill } = require('./loop.js') // at runtime by the isEnabled callback.
/* eslint-enable @typescript-eslint/no-require-imports */
// /loop's isEnabled delegates to isKairosCronEnabled() — same lazy
// per-invocation pattern as the cron tools. Registered unconditionally;
// the skill's own isEnabled callback decides visibility.
registerLoopSkill() registerLoopSkill()
}
if (feature('AGENT_TRIGGERS_REMOTE')) { if (feature('AGENT_TRIGGERS_REMOTE')) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const { const {

View File

@@ -0,0 +1,125 @@
import { afterEach, expect, test } from 'bun:test'
import { clearBundledSkills, getBundledSkills } from '../bundledSkills.js'
import { registerLoopSkill } from './loop.js'
afterEach(() => {
clearBundledSkills()
})
test('bare /loop returns dynamic maintenance instructions', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
expect(skill).toBeDefined()
expect(skill?.type).toBe('prompt')
const blocks = await skill!.getPromptForCommand('', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('# /loop — dynamic rescheduling')
expect(text).toContain('If .claude/loop.md exists, read it and use it.')
expect(text).toContain('continue any unfinished work from the conversation')
expect(text).toContain('Set the scheduled prompt to this exact text so the next iteration stays in dynamic mode:')
expect(text).toContain('/loop')
})
test('prompt-only /loop returns dynamic rescheduling instructions', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
const blocks = await skill!.getPromptForCommand('check the deploy', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('# /loop — dynamic rescheduling')
expect(text).toContain('check the deploy')
expect(text).toContain('choose the next delay dynamically between 1 minute and 1 hour')
expect(text).toContain('/loop check the deploy')
})
test('interval /loop returns fixed recurring instructions', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
const blocks = await skill!.getPromptForCommand('5m check the deploy', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('# /loop — fixed recurring interval')
expect(text).toContain('Requested interval:')
expect(text).toContain('5m')
expect(text).toContain('Call CronCreate')
expect(text).toContain('recurring: true')
expect(text).toContain('Immediately execute the effective prompt now')
})
test('interval-only /loop becomes fixed maintenance mode', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
const blocks = await skill!.getPromptForCommand('15m', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('# /loop — fixed recurring interval')
expect(text).toContain('15m')
expect(text).toContain('This is a maintenance loop with no explicit prompt.')
expect(text).toContain('Scheduled maintenance loop iteration.')
})
test('trailing every clause parses interval and prompt', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
const blocks = await skill!.getPromptForCommand('check the deploy every 20m', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('# /loop — fixed recurring interval')
expect(text).toContain('20m')
expect(text).toContain('check the deploy')
})
test('trailing every clause with word unit parses correctly', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
const blocks = await skill!.getPromptForCommand('run tests every 5 minutes', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('# /loop — fixed recurring interval')
expect(text).toContain('5m')
expect(text).toContain('run tests')
})
test('"check every PR" is not treated as an interval', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
const blocks = await skill!.getPromptForCommand('check every PR', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('# /loop — dynamic rescheduling')
expect(text).toContain('check every PR')
})
test('human-readable hour unit parses correctly', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
const blocks = await skill!.getPromptForCommand('2h check logs', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('# /loop — fixed recurring interval')
expect(text).toContain('2h')
expect(text).toContain('check logs')
})
test('prompt delimiters are present and unambiguous', async () => {
registerLoopSkill()
const skill = getBundledSkills().find(command => command.name === 'loop')
const blocks = await skill!.getPromptForCommand('5m say hi', {} as never)
const text = (blocks[0] as { text: string }).text
expect(text).toContain('--- BEGIN PROMPT ---')
expect(text).toContain('say hi')
expect(text).toContain('--- END PROMPT ---')
})

View File

@@ -6,87 +6,218 @@ import {
} from '../../tools/ScheduleCronTool/prompt.js' } from '../../tools/ScheduleCronTool/prompt.js'
import { registerBundledSkill } from '../bundledSkills.js' import { registerBundledSkill } from '../bundledSkills.js'
const DEFAULT_INTERVAL = '10m' type LoopMode =
| 'dynamic-prompt'
| 'dynamic-maintenance'
| 'fixed-prompt'
| 'fixed-maintenance'
const USAGE_MESSAGE = `Usage: /loop [interval] <prompt> type ParsedLoopArgs = {
mode: LoopMode
interval?: string
prompt?: string
}
Run a prompt or slash command on a recurring interval. const DYNAMIC_MIN_DELAY = '1 minute'
const DYNAMIC_MAX_DELAY = '1 hour'
Intervals: Ns, Nm, Nh, Nd (e.g. 5m, 30m, 2h, 1d). Minimum granularity is 1 minute. const MAINTENANCE_PROMPT = `Scheduled maintenance loop iteration.
If no interval is specified, defaults to ${DEFAULT_INTERVAL}.
Examples: If .claude/loop.md exists, read it and follow it.
/loop 5m /babysit-prs Otherwise, if ~/.claude/loop.md exists, read it and follow it.
/loop 30m check the deploy Otherwise:
/loop 1h /standup 1 - continue any unfinished work from the conversation
/loop check the deploy (defaults to ${DEFAULT_INTERVAL}) - tend to the current branch's pull request: review comments, failed CI runs, merge conflicts
/loop check the deploy every 20m` - run cleanup passes such as bug hunts or simplification when nothing else is pending
function buildPrompt(args: string): string { Do not start new initiatives outside that scope.
return `# /loop — schedule a recurring prompt Irreversible actions such as pushing or deleting only proceed when they continue something the transcript already authorized.`
Parse the input below into \`[interval] <prompt…>\` and schedule it with ${CRON_CREATE_TOOL_NAME}. function normalizeIntervalUnit(rawUnit: string): 's' | 'm' | 'h' | 'd' | null {
const unit = rawUnit.toLowerCase()
if (['s', 'sec', 'secs', 'second', 'seconds'].includes(unit)) return 's'
if (['m', 'min', 'mins', 'minute', 'minutes'].includes(unit)) return 'm'
if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return 'h'
if (['d', 'day', 'days'].includes(unit)) return 'd'
return null
}
## Parsing (in priority order) function parseIntervalToken(token: string): string | null {
const match = token.trim().match(/^(\d+)\s*([a-zA-Z]+)$/)
if (!match) return null
const value = Number.parseInt(match[1]!, 10)
if (!Number.isFinite(value) || value < 1) return null
const unit = normalizeIntervalUnit(match[2]!)
if (!unit) return null
return `${value}${unit}`
}
1. **Leading token**: if the first whitespace-delimited token matches \`^\\d+[smhd]$\` (e.g. \`5m\`, \`2h\`), that's the interval; the rest is the prompt. function parseTrailingEveryClause(input: string): {
2. **Trailing "every" clause**: otherwise, if the input ends with \`every <N><unit>\` or \`every <N> <unit-word>\` (e.g. \`every 20m\`, \`every 5 minutes\`, \`every 2 hours\`), extract that as the interval and strip it from the prompt. Only match when what follows "every" is a time expression — \`check every PR\` has no interval. prompt: string
3. **Default**: otherwise, interval is \`${DEFAULT_INTERVAL}\` and the entire input is the prompt. interval: string
} | null {
const match = input.match(/^(.*?)(?:\s+every\s+)(\d+)\s*([a-zA-Z]+)\s*$/i)
if (!match) return null
const interval = parseIntervalToken(`${match[2]!}${match[3]!}`)
if (!interval) return null
return {
prompt: match[1]!.trim(),
interval,
}
}
If the resulting prompt is empty, show usage \`/loop [interval] <prompt>\` and stop — do not call ${CRON_CREATE_TOOL_NAME}. function parseLoopArgs(args: string): ParsedLoopArgs {
const trimmed = args.trim()
if (!trimmed) return { mode: 'dynamic-maintenance' }
Examples: const bareInterval = parseIntervalToken(trimmed)
- \`5m /babysit-prs\` → interval \`5m\`, prompt \`/babysit-prs\` (rule 1) if (bareInterval) {
- \`check the deploy every 20m\` → interval \`20m\`, prompt \`check the deploy\` (rule 2) return { mode: 'fixed-maintenance', interval: bareInterval }
- \`run tests every 5 minutes\` → interval \`5m\`, prompt \`run tests\` (rule 2) }
- \`check the deploy\` → interval \`${DEFAULT_INTERVAL}\`, prompt \`check the deploy\` (rule 3)
- \`check every PR\` → interval \`${DEFAULT_INTERVAL}\`, prompt \`check every PR\` (rule 3 — "every" not followed by time)
- \`5m\` → empty prompt → show usage
## Interval → cron const [firstToken, ...restTokens] = trimmed.split(/\s+/)
const leadingInterval = parseIntervalToken(firstToken ?? '')
if (leadingInterval) {
const prompt = restTokens.join(' ').trim()
if (!prompt) return { mode: 'fixed-maintenance', interval: leadingInterval }
return {
mode: 'fixed-prompt',
interval: leadingInterval,
prompt,
}
}
Supported suffixes: \`s\` (seconds, rounded up to nearest minute, min 1), \`m\` (minutes), \`h\` (hours), \`d\` (days). Convert: const trailingEvery = parseTrailingEveryClause(trimmed)
if (trailingEvery) {
if (!trailingEvery.prompt) {
return {
mode: 'fixed-maintenance',
interval: trailingEvery.interval,
}
}
return {
mode: 'fixed-prompt',
interval: trailingEvery.interval,
prompt: trailingEvery.prompt,
}
}
| Interval pattern | Cron expression | Notes | return {
|-----------------------|---------------------|------------------------------------------| mode: 'dynamic-prompt',
| \`Nm\` where N ≤ 59 | \`*/N * * * *\` | every N minutes | prompt: trimmed,
| \`Nm\` where N ≥ 60 | \`0 */H * * *\` | round to hours (H = N/60, must divide 24)| }
| \`Nh\` where N ≤ 23 | \`0 */N * * *\` | every N hours | }
| \`Nd\` | \`0 0 */N * *\` | every N days at midnight local |
| \`Ns\` | treat as \`ceil(N/60)m\` | cron minimum granularity is 1 minute |
**If the interval doesn't cleanly divide its unit** (e.g. \`7m\`\`*/7 * * * *\` gives uneven gaps at :56→:00; \`90m\` → 1.5h which cron can't express), pick the nearest clean interval and tell the user what you rounded to before scheduling. function buildFixedPrompt(parsed: ParsedLoopArgs): string {
const targetInstructions = parsed.prompt
? `Use this prompt verbatim for both the immediate run and the recurring scheduled task:
## Action --- BEGIN PROMPT ---
${parsed.prompt}
--- END PROMPT ---
`
: `This is a maintenance loop with no explicit prompt.
1. Call ${CRON_CREATE_TOOL_NAME} with: For the recurring scheduled task, use this exact maintenance prompt body:
- \`cron\`: the expression from the table above
- \`prompt\`: the parsed prompt from above, verbatim (slash commands are passed through unchanged)
- \`recurring\`: \`true\`
2. Briefly confirm: what's scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days, and that they can cancel sooner with ${CRON_DELETE_TOOL_NAME} (include the job ID).
3. **Then immediately execute the parsed prompt now** — don't wait for the first cron fire. If it's a slash command, invoke it via the Skill tool; otherwise act on it directly.
## Input --- BEGIN MAINTENANCE PROMPT ---
${MAINTENANCE_PROMPT}
--- END MAINTENANCE PROMPT ---
`
${args}` return `# /loop — fixed recurring interval
The user invoked /loop with a fixed interval.
Requested interval: ${parsed.interval}
${targetInstructions}
## Instructions
1. Convert the requested interval to a recurring cron expression.
- Supported suffixes: s, m, h, d.
- Seconds must be rounded up to the nearest minute because cron has minute granularity.
- If the requested interval does not map cleanly to cron cadence, choose the nearest clean recurring interval and tell the user what you picked.
2. Call ${CRON_CREATE_TOOL_NAME} with:
- the recurring cron expression
- the effective prompt body above
- recurring: true
- durable: false
3. Briefly confirm what was scheduled, the cron expression, the human cadence, that recurring tasks auto-expire after ${DEFAULT_MAX_AGE_DAYS} days, and that the user can cancel sooner with ${CRON_DELETE_TOOL_NAME} using the returned job ID.
4. Immediately execute the effective prompt now — do not wait for the first cron fire.
- If the effective prompt starts with a slash command, invoke it via the Skill tool.
- Otherwise, act on it directly.
`
}
function buildDynamicPrompt(parsed: ParsedLoopArgs): string {
const effectivePromptInstructions = parsed.prompt
? `Use this prompt verbatim as the effective prompt for this iteration:
--- BEGIN PROMPT ---
${parsed.prompt}
--- END PROMPT ---
`
: `This is a maintenance loop with no explicit prompt.
Determine the effective prompt in this order:
1. If .claude/loop.md exists, read it and use it.
2. Otherwise, if ~/.claude/loop.md exists, read it and use it.
3. Otherwise, use this built-in maintenance prompt:
--- BEGIN MAINTENANCE PROMPT ---
${MAINTENANCE_PROMPT}
--- END MAINTENANCE PROMPT ---
`
const reschedulePrompt = parsed.prompt ? `/loop ${parsed.prompt}` : '/loop'
return `# /loop — dynamic rescheduling
The user invoked /loop without a fixed interval.
${effectivePromptInstructions}
## Instructions
1. Execute the effective prompt now.
- If it starts with a slash command, invoke it via the Skill tool.
- Otherwise, act on it directly.
2. After the work finishes, choose the next delay dynamically between ${DYNAMIC_MIN_DELAY} and ${DYNAMIC_MAX_DELAY}.
- Use shorter delays while active work is progressing or likely to change soon.
- Use longer delays when the situation is quiet or stable.
3. Briefly tell the user the chosen delay and the reason.
4. Schedule exactly one session-only follow-up run with ${CRON_CREATE_TOOL_NAME}.
- Use recurring: false.
- Use durable: false.
- Pin the cron expression to a specific future local-time minute that matches the chosen delay.
- Set the scheduled prompt to this exact text so the next iteration stays in dynamic mode:
--- BEGIN SCHEDULED PROMPT ---
${reschedulePrompt}
--- END SCHEDULED PROMPT ---
5. Confirm the next run time and the returned job ID.
6. Do not create a recurring cron for this mode.
`
} }
export function registerLoopSkill(): void { export function registerLoopSkill(): void {
registerBundledSkill({ registerBundledSkill({
name: 'loop', name: 'loop',
description: description:
'Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo, defaults to 10m)', 'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.',
whenToUse: whenToUse:
'When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.', 'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.',
argumentHint: '[interval] <prompt>', argumentHint: '[interval] [prompt]',
userInvocable: true, userInvocable: true,
isEnabled: isKairosCronEnabled, isEnabled: isKairosCronEnabled,
async getPromptForCommand(args) { async getPromptForCommand(args) {
const trimmed = args.trim() const parsed = parseLoopArgs(args)
if (!trimmed) { const text =
return [{ type: 'text', text: USAGE_MESSAGE }] parsed.mode === 'fixed-prompt' || parsed.mode === 'fixed-maintenance'
} ? buildFixedPrompt(parsed)
return [{ type: 'text', text: buildPrompt(trimmed) }] : buildDynamicPrompt(parsed)
return [{ type: 'text', text }]
}, },
}) })
} }

View File

@@ -0,0 +1,102 @@
// MonitorMcpTask — task registry entry for the 'monitor_mcp' type.
//
// Architecture: MonitorTool spawns shell processes as LocalShellTask
// (type: 'local_bash', kind: 'monitor'). The 'monitor_mcp' type exists
// in TaskType for forward-compatibility with MCP-based monitoring (not
// yet implemented). This module satisfies the import from tasks.ts and
// provides killMonitorMcpTasksForAgent for agent-scoped cleanup of
// monitor-kind shell tasks.
import type { AppState } from '../../state/AppState.js'
import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
import type { AgentId } from '../../types/ids.js'
import { logForDebugging } from '../../utils/debug.js'
import { dequeueAllMatching } from '../../utils/messageQueueManager.js'
import { evictTaskOutput } from '../../utils/task/diskOutput.js'
import { updateTaskState } from '../../utils/task/framework.js'
import { isLocalShellTask } from '../LocalShellTask/guards.js'
import { killTask } from '../LocalShellTask/killShellTasks.js'
export type MonitorMcpTaskState = TaskStateBase & {
type: 'monitor_mcp'
agentId?: AgentId
}
function isMonitorMcpTask(task: unknown): task is MonitorMcpTaskState {
return (
typeof task === 'object' &&
task !== null &&
'type' in task &&
task.type === 'monitor_mcp'
)
}
export const MonitorMcpTask: Task = {
name: 'MonitorMcpTask',
type: 'monitor_mcp',
async kill(taskId, setAppState) {
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => {
if (task.status !== 'running') {
return task
}
return {
...task,
status: 'killed',
notified: true,
endTime: Date.now(),
}
})
void evictTaskOutput(taskId)
},
}
/**
* Kill all monitor tasks owned by a given agent.
*
* MonitorTool spawns tasks as local_bash with kind='monitor'. When an agent
* exits, killShellTasksForAgent already handles those. This function provides
* additional cleanup for any monitor_mcp-typed tasks and also kills any
* local_bash tasks with kind='monitor' that might have been missed (belt and
* suspenders). Finally, it purges queued notifications for the dead agent.
*/
export function killMonitorMcpTasksForAgent(
agentId: AgentId,
getAppState: () => AppState,
setAppState: SetAppState,
): void {
const tasks = getAppState().tasks ?? {}
for (const [taskId, task] of Object.entries(tasks)) {
// Kill monitor_mcp tasks for this agent
if (
isMonitorMcpTask(task) &&
task.agentId === agentId &&
task.status === 'running'
) {
logForDebugging(
`killMonitorMcpTasksForAgent: killing monitor_mcp task ${taskId} (agent ${agentId} exiting)`,
)
void MonitorMcpTask.kill(taskId, setAppState)
}
// Also kill local_bash tasks with kind='monitor' for this agent
// (killShellTasksForAgent already does this, but being explicit
// guards against ordering issues)
if (
isLocalShellTask(task) &&
task.kind === 'monitor' &&
task.agentId === agentId &&
task.status === 'running'
) {
logForDebugging(
`killMonitorMcpTasksForAgent: killing monitor shell task ${taskId} (agent ${agentId} exiting)`,
)
killTask(taskId, setAppState)
}
}
// Purge any queued notifications addressed to this agent — its query loop
// has exited and won't drain them.
dequeueAllMatching(cmd => cmd.agentId === agentId)
}

View File

@@ -12,27 +12,18 @@ import { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'
import { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js' import { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
import { BriefTool } from './tools/BriefTool/BriefTool.js' import { BriefTool } from './tools/BriefTool/BriefTool.js'
// Dead code elimination: conditional import for internal-only tools // Dead code elimination: conditional import for internal-only tools
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const REPLTool = const REPLTool = null
process.env.USER_TYPE === 'ant' const SuggestBackgroundPRTool = null
? require('./tools/REPLTool/REPLTool.js').REPLTool
: null
const SuggestBackgroundPRTool =
process.env.USER_TYPE === 'ant'
? require('./tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js')
.SuggestBackgroundPRTool
: null
const SleepTool = const SleepTool =
feature('PROACTIVE') || feature('KAIROS') feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool ? require('./tools/SleepTool/SleepTool.js').SleepTool
: null : null
const cronTools = feature('AGENT_TRIGGERS') const cronTools = [
? [
require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool, require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool, require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool, require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
] ]
: []
const RemoteTriggerTool = feature('AGENT_TRIGGERS_REMOTE') const RemoteTriggerTool = feature('AGENT_TRIGGERS_REMOTE')
? require('./tools/RemoteTriggerTool/RemoteTriggerTool.js').RemoteTriggerTool ? require('./tools/RemoteTriggerTool/RemoteTriggerTool.js').RemoteTriggerTool
: null : null
@@ -57,7 +48,7 @@ import { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
import { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' import { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
import { TestingPermissionTool } from './tools/testing/TestingPermissionTool.js' import { TestingPermissionTool } from './tools/testing/TestingPermissionTool.js'
import { GrepTool } from './tools/GrepTool/GrepTool.js' import { GrepTool } from './tools/GrepTool/GrepTool.js'
import { TungstenTool } from './tools/TungstenTool/TungstenTool.js' import { RepoMapTool } from './tools/RepoMapTool/RepoMapTool.js'
// Lazy require to break circular dependency: tools.ts -> TeamCreateTool/TeamDeleteTool -> ... -> tools.ts // Lazy require to break circular dependency: tools.ts -> TeamCreateTool/TeamDeleteTool -> ... -> tools.ts
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const getTeamCreateTool = () => const getTeamCreateTool = () =>
@@ -78,7 +69,6 @@ import { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
import { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js' import { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
import { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js' import { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
import { ExitWorktreeTool } from './tools/ExitWorktreeTool/ExitWorktreeTool.js' import { ExitWorktreeTool } from './tools/ExitWorktreeTool/ExitWorktreeTool.js'
import { ConfigTool } from './tools/ConfigTool/ConfigTool.js'
import { TaskCreateTool } from './tools/TaskCreateTool/TaskCreateTool.js' import { TaskCreateTool } from './tools/TaskCreateTool/TaskCreateTool.js'
import { TaskGetTool } from './tools/TaskGetTool/TaskGetTool.js' import { TaskGetTool } from './tools/TaskGetTool/TaskGetTool.js'
import { TaskUpdateTool } from './tools/TaskUpdateTool/TaskUpdateTool.js' import { TaskUpdateTool } from './tools/TaskUpdateTool/TaskUpdateTool.js'
@@ -199,6 +189,7 @@ export function getAllBaseTools(): Tools {
// trick as ripgrep). When available, find/grep in Claude's shell are aliased // trick as ripgrep). When available, find/grep in Claude's shell are aliased
// to these fast tools, so the dedicated Glob/Grep tools are unnecessary. // to these fast tools, so the dedicated Glob/Grep tools are unnecessary.
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]), ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
RepoMapTool,
ExitPlanModeV2Tool, ExitPlanModeV2Tool,
FileReadTool, FileReadTool,
FileEditTool, FileEditTool,
@@ -211,8 +202,6 @@ export function getAllBaseTools(): Tools {
AskUserQuestionTool, AskUserQuestionTool,
SkillTool, SkillTool,
EnterPlanModeTool, EnterPlanModeTool,
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []), ...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
...(WebBrowserTool ? [WebBrowserTool] : []), ...(WebBrowserTool ? [WebBrowserTool] : []),
...(isTodoV2Enabled() ...(isTodoV2Enabled()

View File

@@ -1042,10 +1042,12 @@ export const AgentTool = buildTool({
}); });
} finally { } finally {
stopBackgroundedSummarization?.(); stopBackgroundedSummarization?.();
clearInvokedSkillsForAgent(syncAgentId); // Defensive cleanup: wrap each call so one failure doesn't
clearDumpState(syncAgentId); // prevent the other from running. Without this, if
// Note: worktree cleanup is done before enqueueAgentNotification // clearInvokedSkillsForAgent throws, clearDumpState is
// in both try and catch paths so we can include worktree info // skipped and dump state leaks.
try { clearInvokedSkillsForAgent(syncAgentId); } catch { /* cleanup best-effort */ }
try { clearDumpState(syncAgentId); } catch { /* cleanup best-effort */ }
} }
}); });

View File

@@ -73,9 +73,8 @@ export const EXPLORE_AGENT: BuiltInAgentDefinition = {
], ],
source: 'built-in', source: 'built-in',
baseDir: 'built-in', baseDir: 'built-in',
// Ants get inherit to use the main agent's model; external users get haiku for speed // Use haiku for speed — explore is a fast read-only search agent
// Note: For ants, getAgentModel() checks tengu_explore_agent GrowthBook flag at runtime model: 'haiku',
model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku',
// Explore is a fast read-only search agent — it doesn't need commit/PR/lint // Explore is a fast read-only search agent — it doesn't need commit/PR/lint
// rules from CLAUDE.md. The main agent has full context and interprets results. // rules from CLAUDE.md. The main agent has full context and interprets results.
omitClaudeMd: true, omitClaudeMd: true,

View File

@@ -15,7 +15,6 @@ import {
DOCKER_READ_ONLY_COMMANDS, DOCKER_READ_ONLY_COMMANDS,
EXTERNAL_READONLY_COMMANDS, EXTERNAL_READONLY_COMMANDS,
type FlagArgType, type FlagArgType,
GH_READ_ONLY_COMMANDS,
GIT_READ_ONLY_COMMANDS, GIT_READ_ONLY_COMMANDS,
PYRIGHT_READ_ONLY_COMMANDS, PYRIGHT_READ_ONLY_COMMANDS,
RIPGREP_READ_ONLY_COMMANDS, RIPGREP_READ_ONLY_COMMANDS,
@@ -1136,68 +1135,6 @@ const COMMAND_ALLOWLIST: Record<string, CommandConfig> = {
...DOCKER_READ_ONLY_COMMANDS, ...DOCKER_READ_ONLY_COMMANDS,
} }
// gh commands are internal-only since they make network requests, which goes against
// the read-only validation principle of no network access
const ANT_ONLY_COMMAND_ALLOWLIST: Record<string, CommandConfig> = {
// All gh read-only commands from shared validation map
...GH_READ_ONLY_COMMANDS,
// aki — internal knowledge-base search CLI.
// Network read-only (same policy as gh). --audit-csv omitted: writes to disk.
aki: {
safeFlags: {
'-h': 'none',
'--help': 'none',
'-k': 'none',
'--keyword': 'none',
'-s': 'none',
'--semantic': 'none',
'--no-adaptive': 'none',
'-n': 'number',
'--limit': 'number',
'-o': 'number',
'--offset': 'number',
'--source': 'string',
'--exclude-source': 'string',
'-a': 'string',
'--after': 'string',
'-b': 'string',
'--before': 'string',
'--collection': 'string',
'--drive': 'string',
'--folder': 'string',
'--descendants': 'none',
'-m': 'string',
'--meta': 'string',
'-t': 'string',
'--threshold': 'string',
'--kw-weight': 'string',
'--sem-weight': 'string',
'-j': 'none',
'--json': 'none',
'-c': 'none',
'--chunk': 'none',
'--preview': 'none',
'-d': 'none',
'--full-doc': 'none',
'-v': 'none',
'--verbose': 'none',
'--stats': 'none',
'-S': 'number',
'--summarize': 'number',
'--explain': 'none',
'--examine': 'string',
'--url': 'string',
'--multi-turn': 'number',
'--multi-turn-model': 'string',
'--multi-turn-context': 'string',
'--no-rerank': 'none',
'--audit': 'none',
'--local': 'none',
'--staging': 'none',
},
},
}
function getCommandAllowlist(): Record<string, CommandConfig> { function getCommandAllowlist(): Record<string, CommandConfig> {
let allowlist: Record<string, CommandConfig> = COMMAND_ALLOWLIST let allowlist: Record<string, CommandConfig> = COMMAND_ALLOWLIST
// On Windows, xargs can be used as a data-to-code bridge: if a file contains // On Windows, xargs can be used as a data-to-code bridge: if a file contains
@@ -1208,9 +1145,6 @@ function getCommandAllowlist(): Record<string, CommandConfig> {
const { xargs: _, ...rest } = allowlist const { xargs: _, ...rest } = allowlist
allowlist = rest allowlist = rest
} }
if (process.env.USER_TYPE === 'ant') {
return { ...allowlist, ...ANT_ONLY_COMMAND_ALLOWLIST }
}
return allowlist return allowlist
} }

View File

@@ -19,15 +19,25 @@ type SandboxInput = {
// It is not a security bug to be able to bypass excludedCommands — the sandbox permission // It is not a security bug to be able to bypass excludedCommands — the sandbox permission
// system (which prompts users) is the actual security control. // system (which prompts users) is the actual security control.
function containsExcludedCommand(command: string): boolean { function containsExcludedCommand(command: string): boolean {
// Check dynamic config for disabled commands and substrings (only for ants) // Check dynamic config for disabled commands and substrings
if (process.env.USER_TYPE === 'ant') { const raw = getFeatureValue_CACHED_MAY_BE_STALE<{
const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{
commands: string[] commands: string[]
substrings: string[] substrings: string[]
}>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] }) }>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
const disabledCommands =
typeof raw === 'object' && raw !== null
? raw
: { commands: [], substrings: [] }
const substrings = Array.isArray(disabledCommands.substrings)
? disabledCommands.substrings
: []
const commands = Array.isArray(disabledCommands.commands)
? disabledCommands.commands
: []
// Check if command contains any disabled substrings // Check if command contains any disabled substrings
for (const substring of disabledCommands.substrings) { for (const substring of substrings) {
if (command.includes(substring)) { if (command.includes(substring)) {
return true return true
} }
@@ -38,7 +48,7 @@ function containsExcludedCommand(command: string): boolean {
const commandParts = splitCommand_DEPRECATED(command) const commandParts = splitCommand_DEPRECATED(command)
for (const part of commandParts) { for (const part of commandParts) {
const baseCommand = part.trim().split(' ')[0] const baseCommand = part.trim().split(' ')[0]
if (baseCommand && disabledCommands.commands.includes(baseCommand)) { if (baseCommand && commands.includes(baseCommand)) {
return true return true
} }
} }
@@ -47,7 +57,6 @@ function containsExcludedCommand(command: string): boolean {
// treat it as not excluded to allow other validation checks to handle it // treat it as not excluded to allow other validation checks to handle it
// This prevents crashes when rendering tool use messages // This prevents crashes when rendering tool use messages
} }
}
// Check user-configured excluded commands from settings // Check user-configured excluded commands from settings
const settings = getSettings_DEPRECATED() const settings = getSettings_DEPRECATED()

View File

@@ -98,73 +98,6 @@ User: "What files handle routing?"
` `
} }
function getEnterPlanModeToolPromptAnt(): string {
// When interview phase is enabled, omit the "What Happens" section —
// detailed workflow instructions arrive via the plan_mode attachment (messages.ts).
const whatHappens = isPlanModeInterviewPhaseEnabled()
? ''
: WHAT_HAPPENS_SECTION
return `Use this tool when a task has genuine ambiguity about the right approach and getting user input before coding would prevent significant rework. This tool transitions you into plan mode where you can explore the codebase and design an implementation approach for user approval.
## When to Use This Tool
Plan mode is valuable when the implementation approach is genuinely unclear. Use it when:
1. **Significant Architectural Ambiguity**: Multiple reasonable approaches exist and the choice meaningfully affects the codebase
- Example: "Add caching to the API" - Redis vs in-memory vs file-based
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
2. **Unclear Requirements**: You need to explore and clarify before you can make progress
- Example: "Make the app faster" - need to profile and identify bottlenecks
- Example: "Refactor this module" - need to understand what the target architecture should be
3. **High-Impact Restructuring**: The task will significantly restructure existing code and getting buy-in first reduces risk
- Example: "Redesign the authentication system"
- Example: "Migrate from one state management approach to another"
## When NOT to Use This Tool
Skip plan mode when you can reasonably infer the right approach:
- The task is straightforward even if it touches multiple files
- The user's request is specific enough that the implementation path is clear
- You're adding a feature with an obvious implementation pattern (e.g., adding a button, a new endpoint following existing conventions)
- Bug fixes where the fix is clear once you understand the bug
- Research/exploration tasks (use the Agent tool instead)
- The user says something like "can we work on X" or "let's do X" — just get started
When in doubt, prefer starting work and using ${ASK_USER_QUESTION_TOOL_NAME} for specific questions over entering a full planning phase.
${whatHappens}## Examples
### GOOD - Use EnterPlanMode:
User: "Add user authentication to the app"
- Genuinely ambiguous: session vs JWT, where to store tokens, middleware structure
User: "Redesign the data pipeline"
- Major restructuring where the wrong approach wastes significant effort
### BAD - Don't use EnterPlanMode:
User: "Add a delete button to the user profile"
- Implementation path is clear; just do it
User: "Can we work on the search feature?"
- User wants to get started, not plan
User: "Update the error handling in the API"
- Start working; ask specific questions if needed
User: "Fix the typo in the README"
- Straightforward, no planning needed
## Important Notes
- This tool REQUIRES user approval - they must consent to entering plan mode
`
}
export function getEnterPlanModeToolPrompt(): string { export function getEnterPlanModeToolPrompt(): string {
return process.env.USER_TYPE === 'ant' return getEnterPlanModeToolPromptExternal()
? getEnterPlanModeToolPromptAnt()
: getEnterPlanModeToolPromptExternal()
} }

View File

@@ -11,7 +11,7 @@ export function getEditToolDescription(): string {
function getDefaultEditDescription(): string { function getDefaultEditDescription(): string {
const prefixFormat = isCompactLinePrefixEnabled() const prefixFormat = isCompactLinePrefixEnabled()
? 'line number + tab' ? 'line number + arrow'
: 'spaces + line number + arrow' : 'spaces + line number + arrow'
const minimalUniquenessHint = const minimalUniquenessHint =
process.env.USER_TYPE === 'ant' process.env.USER_TYPE === 'ant'

View File

@@ -0,0 +1,195 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import React from 'react'
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from '../../Tool.js'
import { spawnShellTask } from '../../tasks/LocalShellTask/LocalShellTask.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { exec } from '../../utils/Shell.js'
import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
import {
bashToolHasPermission,
matchWildcardPattern,
permissionRuleExtractPrefix,
} from '../BashTool/bashPermissions.js'
import { parseForSecurity } from '../../utils/bash/ast.js'
export const MONITOR_TOOL_NAME = 'Monitor'
const MONITOR_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
const inputSchema = lazySchema(() =>
z.strictObject({
command: z
.string()
.describe('The shell command to run and monitor'),
description: z
.string()
.describe(
'Clear, concise description of what this command does in active voice.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
taskId: z
.string()
.describe('The ID of the background monitor task'),
outputFile: z
.string()
.describe('Path to the file where output is being written'),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
type Output = z.infer<OutputSchema>
export const MonitorTool = buildTool({
name: MONITOR_TOOL_NAME,
searchHint: 'stream shell output as notifications',
maxResultSizeChars: 10_000,
strict: true,
isConcurrencySafe() {
return true
},
toAutoClassifierInput(input) {
return input.command
},
async preparePermissionMatcher({ command }) {
const parsed = await parseForSecurity(command)
if (parsed.kind !== 'simple') {
return () => true
}
const subcommands = parsed.commands.map(c => c.argv.join(' '))
return (pattern: string) => {
const prefix = permissionRuleExtractPrefix(pattern)
return subcommands.some(cmd => {
if (prefix !== null) {
return cmd === prefix || cmd.startsWith(`${prefix} `)
}
return matchWildcardPattern(pattern, cmd)
})
}
},
async checkPermissions(input, context) {
// Delegate to the bash permission system — Monitor runs shell commands
// just like Bash does, so the same permission rules apply.
return bashToolHasPermission({ command: input.command }, context)
},
async description(input) {
return input.description || 'Monitor shell command'
},
async prompt() {
return `Execute a shell command in the background and stream its stdout line-by-line as notifications. Each polling interval (~1s), new output lines are delivered to you. Use this for monitoring logs, watching build output, or observing long-running processes. For one-shot "wait until done" commands, prefer Bash with run_in_background instead.`
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Monitor'
},
getToolUseSummary(input) {
if (!input?.description) {
return input?.command ?? null
}
return input.description
},
getActivityDescription(input) {
if (!input?.description) {
return 'Starting monitor'
}
return `Monitoring ${input.description}`
},
renderToolUseMessage(
input: Partial<z.infer<InputSchema>>,
): React.ReactNode {
const cmd = input.command ?? ''
const desc = input.description ?? ''
if (desc && cmd) {
return `${desc}: ${cmd}`
}
return cmd || desc || ''
},
renderToolResultMessage(
output: Output,
): React.ReactNode {
return `Monitor started (task ${output.taskId})`
},
mapToolResultToToolResultBlockParam(
output: Output,
toolUseID: string,
): ToolResultBlockParam {
const outputPath = output.outputFile
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `Monitor task started with ID: ${output.taskId}. Output is being streamed to: ${outputPath}. You will receive notifications as new output lines appear (~1s polling). Use TaskStop to end monitoring when done.`,
}
},
async call(input, toolUseContext) {
const { command, description } = input
const { abortController, setAppState } = toolUseContext
// Create the shell command — uses the same Shell.exec() as BashTool.
// This is intentionally a shell execution (not execFile) because
// MonitorTool needs full shell features (pipes, redirects, etc.)
// just like BashTool does.
const shellCommand = await exec(
command,
abortController.signal,
'bash',
{ timeout: MONITOR_TIMEOUT_MS },
)
// Spawn as a background task with kind='monitor' — identical to
// BashTool's run_in_background path but always monitor-flavored.
const handle = await spawnShellTask(
{
command,
description: description || command,
shellCommand,
toolUseId: toolUseContext.toolUseId,
agentId: toolUseContext.agentId,
kind: 'monitor',
},
{
abortController,
getAppState: () => {
throw new Error(
'getAppState not available in MonitorTool spawn context',
)
},
setAppState: toolUseContext.setAppStateForTasks ?? setAppState,
},
)
const taskId = handle.taskId
const outputFile = getTaskOutputPath(taskId)
return {
data: {
taskId,
outputFile,
},
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,167 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { cpSync, mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { initParser } from '../../context/repoMap/parser.js'
import { invalidateCache } from '../../context/repoMap/index.js'
import { RepoMapTool } from './RepoMapTool.js'
import { getToolUseSummary } from './UI.js'
const FIXTURE_ROOT = join(
import.meta.dir,
'..',
'..',
'context',
'repoMap',
'__fixtures__',
'mini-repo',
)
const FIXTURE_FILES = [
'fileA.ts',
'fileB.ts',
'fileC.ts',
'fileD.ts',
'fileE.ts',
]
beforeAll(async () => {
await initParser()
})
describe('RepoMapTool schema', () => {
test('validates a minimal input {}', () => {
const schema = RepoMapTool.inputSchema
const result = schema.safeParse({})
expect(result.success).toBe(true)
})
test('rejects max_tokens below 256', () => {
const schema = RepoMapTool.inputSchema
const result = schema.safeParse({ max_tokens: 100 })
expect(result.success).toBe(false)
})
test('rejects max_tokens above 16384', () => {
const schema = RepoMapTool.inputSchema
const result = schema.safeParse({ max_tokens: 20000 })
expect(result.success).toBe(false)
})
test('accepts focus_files as string[]', () => {
const schema = RepoMapTool.inputSchema
const result = schema.safeParse({
focus_files: ['src/tools/', 'src/context.ts'],
})
expect(result.success).toBe(true)
})
})
describe('RepoMapTool call', () => {
test('returns a rendered map for a directory', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-tool-'))
try {
for (const f of FIXTURE_FILES) {
cpSync(join(FIXTURE_ROOT, f), join(tempDir, f))
}
// We need to call buildRepoMap directly since getCwd patching is complex
const { buildRepoMap } = await import(
'../../context/repoMap/index.js'
)
const result = await buildRepoMap({
root: tempDir,
maxTokens: 1024,
})
expect(result.map.length).toBeGreaterThan(0)
expect(result.fileCount).toBeGreaterThan(0)
expect(result.totalFileCount).toBe(5)
expect(result.tokenCount).toBeGreaterThan(0)
expect(result.tokenCount).toBeLessThanOrEqual(1024)
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
test('respects max_tokens parameter', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-tool-'))
try {
for (const f of FIXTURE_FILES) {
cpSync(join(FIXTURE_ROOT, f), join(tempDir, f))
}
const { buildRepoMap } = await import(
'../../context/repoMap/index.js'
)
const small = await buildRepoMap({ root: tempDir, maxTokens: 256 })
const large = await buildRepoMap({ root: tempDir, maxTokens: 4096 })
expect(small.tokenCount).toBeLessThanOrEqual(256)
// Large budget should include more or equal content
expect(large.map.length).toBeGreaterThanOrEqual(small.map.length)
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
test('focus_files boosts specified files in the ranking', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'repomap-tool-'))
try {
for (const f of FIXTURE_FILES) {
cpSync(join(FIXTURE_ROOT, f), join(tempDir, f))
}
const { buildRepoMap } = await import(
'../../context/repoMap/index.js'
)
// Without focus, fileE is ranked last (isolated)
const noFocus = await buildRepoMap({ root: tempDir, maxTokens: 2048 })
const lines = noFocus.map.split('\n')
const fileEPos = lines.findIndex(l => l === 'fileE.ts:')
// With focus on fileE
invalidateCache(tempDir)
const withFocus = await buildRepoMap({
root: tempDir,
maxTokens: 2048,
focusFiles: ['fileE.ts'],
})
const focusLines = withFocus.map.split('\n')
const fileEFocusPos = focusLines.findIndex(l => l === 'fileE.ts:')
// fileE should rank higher (earlier position) with focus
expect(fileEFocusPos).toBeLessThan(fileEPos)
} finally {
rmSync(tempDir, { recursive: true, force: true })
invalidateCache(tempDir)
}
})
})
describe('RepoMapTool properties', () => {
test('is marked read-only and concurrency-safe', () => {
expect(RepoMapTool.isReadOnly({})).toBe(true)
expect(RepoMapTool.isConcurrencySafe({})).toBe(true)
})
})
describe('RepoMapTool UI', () => {
test('getToolUseSummary returns descriptive string including focus', () => {
expect(getToolUseSummary(undefined)).toBe('Repository map')
expect(getToolUseSummary({})).toBe('Repository map')
expect(getToolUseSummary({ focus_files: ['src/tools/'] })).toContain(
'focus:',
)
expect(getToolUseSummary({ focus_files: ['src/tools/'] })).toContain(
'src/tools/',
)
expect(
getToolUseSummary({ focus_symbols: ['buildTool'] }),
).toContain('buildTool')
})
})

View File

@@ -0,0 +1,176 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from '../../Tool.js'
import { getCwd } from '../../utils/cwd.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { checkReadPermissionForTool } from '../../utils/permissions/filesystem.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
import { buildRepoMap } from '../../context/repoMap/index.js'
import { REPO_MAP_TOOL_NAME, getDescription } from './prompt.js'
import {
getToolUseSummary,
renderToolResultMessage,
renderToolUseErrorMessage,
renderToolUseMessage,
} from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
max_tokens: z
.number()
.int()
.min(256)
.max(16384)
.optional()
.describe(
'Maximum token budget for the rendered map. Higher values include more files. Default: 1024.',
),
focus_files: z
.array(z.string())
.optional()
.describe(
'Relative file or directory paths to boost in the ranking (e.g. ["src/tools/", "src/context.ts"]).',
),
focus_symbols: z
.array(z.string())
.optional()
.describe(
'Symbol names to boost — files defining these symbols rank higher (e.g. ["buildTool", "ToolUseContext"]).',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
rendered: z.string(),
token_count: z.number(),
file_count: z.number(),
total_file_count: z.number(),
cache_hit: z.boolean(),
build_time_ms: z.number(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
type Output = z.infer<OutputSchema>
export const RepoMapTool = buildTool({
name: REPO_MAP_TOOL_NAME,
searchHint: 'structural map of repository files and symbols',
maxResultSizeChars: 50_000,
async description() {
return getDescription()
},
userFacingName() {
return 'Repository map'
},
getToolUseSummary,
getActivityDescription(input) {
if (input?.focus_files?.length) {
return `Building repository map (focus: ${input.focus_files.join(', ')})`
}
return 'Building repository map'
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
isConcurrencySafe() {
return true
},
isReadOnly() {
return true
},
isSearchOrReadCommand() {
return { isSearch: false, isRead: true }
},
toAutoClassifierInput(input) {
const parts: string[] = ['repomap']
if (input.focus_files?.length) parts.push(`focus: ${input.focus_files.join(',')}`)
return parts.join(' ')
},
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkReadPermissionForTool(
RepoMapTool,
input,
appState.toolPermissionContext,
)
},
async prompt() {
return getDescription()
},
renderToolUseMessage,
renderToolUseErrorMessage,
renderToolResultMessage,
extractSearchText({ rendered }) {
return rendered
},
mapToolResultToToolResultBlockParam(output, toolUseID) {
const summary = [
`Repository map: ${output.file_count} files ranked (${output.total_file_count} total), ${output.token_count} tokens`,
output.cache_hit ? '(cached)' : `(built in ${output.build_time_ms}ms)`,
].join(' ')
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: `${summary}\n\n${output.rendered}`,
}
},
async call(
{ max_tokens = 1024, focus_files, focus_symbols },
{ abortController },
) {
const root = getCwd()
// Resolve focus_symbols to file paths by searching the tag cache
let resolvedFocusFiles = focus_files ?? []
if (focus_symbols?.length) {
// Import the symbol lookup dynamically to avoid circular deps at module load
const { getRepoFiles } = await import('../../context/repoMap/gitFiles.js')
const { extractTags } = await import('../../context/repoMap/symbolExtractor.js')
const { initParser } = await import('../../context/repoMap/parser.js')
await initParser()
const files = await getRepoFiles(root)
const symbolFiles: string[] = []
const symbolSet = new Set(focus_symbols)
// Scan files for matching symbol definitions
for (const file of files) {
if (abortController.signal.aborted) break
const tags = await extractTags(file, root)
if (tags) {
const hasMatch = tags.tags.some(
t => t.kind === 'def' && symbolSet.has(t.name),
)
if (hasMatch) {
symbolFiles.push(file)
}
}
}
resolvedFocusFiles = [...resolvedFocusFiles, ...symbolFiles]
}
const result = await buildRepoMap({
root,
maxTokens: max_tokens,
focusFiles: resolvedFocusFiles.length > 0 ? resolvedFocusFiles : undefined,
})
const output: Output = {
rendered: result.map,
token_count: result.tokenCount,
file_count: result.fileCount,
total_file_count: result.totalFileCount,
cache_hit: result.cacheHit,
build_time_ms: result.buildTimeMs,
}
return { data: output }
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,96 @@
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import React from 'react'
import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'
import { MessageResponse } from '../../components/MessageResponse.js'
import { TOOL_SUMMARY_MAX_LENGTH } from '../../constants/toolLimits.js'
import { Text } from '../../ink.js'
import type { ToolProgressData } from '../../Tool.js'
import type { ProgressMessage } from '../../types/message.js'
import { truncate } from '../../utils/format.js'
type Output = {
rendered: string
token_count: number
file_count: number
total_file_count: number
cache_hit: boolean
build_time_ms: number
}
export function getToolUseSummary(
input:
| Partial<{
max_tokens?: number
focus_files?: string[]
focus_symbols?: string[]
}>
| undefined,
): string | null {
if (!input) return 'Repository map'
const parts: string[] = []
if (input.focus_files?.length) {
parts.push(input.focus_files.join(', '))
}
if (input.focus_symbols?.length) {
parts.push(input.focus_symbols.join(', '))
}
if (parts.length > 0) {
return truncate(`Repository map (focus: ${parts.join('; ')})`, TOOL_SUMMARY_MAX_LENGTH)
}
return 'Repository map'
}
export function renderToolUseMessage(
input: Partial<{
max_tokens?: number
focus_files?: string[]
focus_symbols?: string[]
}>,
): React.ReactNode {
const parts: string[] = []
if (input.max_tokens) {
parts.push(`max_tokens: ${input.max_tokens}`)
}
if (input.focus_files?.length) {
parts.push(`focus: ${input.focus_files.join(', ')}`)
}
if (input.focus_symbols?.length) {
parts.push(`symbols: ${input.focus_symbols.join(', ')}`)
}
return parts.length > 0 ? parts.join(', ') : null
}
export function renderToolResultMessage(
output: Output,
_progressMessages: ProgressMessage<ToolProgressData>[],
{ verbose }: { verbose: boolean },
): React.ReactNode {
const summary = `${output.file_count} files ranked, ${output.token_count} tokens${output.cache_hit ? ' (cached)' : `, ${output.build_time_ms}ms`}`
if (verbose) {
return (
<MessageResponse>
<Text>
Built repository map: {summary}
{'\n'}
({output.total_file_count} total files considered)
</Text>
</MessageResponse>
)
}
return (
<MessageResponse height={1}>
<Text>
Built repository map: {summary}
</Text>
</MessageResponse>
)
}
export function renderToolUseErrorMessage(
result: ToolResultBlockParam['content'],
{ verbose }: { verbose: boolean },
): React.ReactNode {
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />
}

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