974 Commits
Author SHA1 Message Date
d683e85395 feat(buddy): hero pixel-art companions with signature Enter animations (#1972)
* feat(buddy): hero pixel-art companions with signature Enter animations

Rebuild the buddy system as heroes-only. The 18 legacy rolled species are
removed; the hatch pool is now 7 hero forms — robinhood, kaio, strawhat,
merlin, kage, ember, corsair — each hand-pickable via /buddy set.

Every hero has 22x16 truecolor half-block pixel art (idle + action poses),
a line-art fallback for low-color terminals, a narrow-mode face, and a
signature effect that fires on every message submission: arrow with impact
thunk, charging full-width energy wave, stretchy punch that extends and
snaps back, twinkling sparkle stream, spinning shuriken, gradient fire
cone, and cannonball with smoke trail.

Engine: companion animation moves from a raw 500ms setInterval to the
shared animation clock (useAnimationFrame; pauses when hidden, respects
prefersReducedMotion), with a one-shot 50ms burst driver (useShotClock,
arm-then-anchor to avoid stale-tick draw-phase skips) and a general
ActionEffect system (pure draw/travel/impact functions, frame-tested).
Effects travel right-to-left toward the prompt — matching where the
sprite actually stands.

Commands: /buddy set <form|random>, /buddy name <name>, muted-buddy
feedback (silent no-op pets now explain themselves), and a hatch-message
fix so the announced species always matches the displayed sprite (the
message previously rolled with a different seed).

BREAKING: existing rolled pets transform into a hero on upgrade (name and
personality persist; speciesOverride pins are unaffected).

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

* fix(buddy): address CodeRabbit review on PR #1972

- useShotClock: consume an in-flight shot when playback becomes
  ineligible mid-flight (mute/reduced-motion/resize), so re-enabling
  can't resume a stale animation.
- /buddy unmute: emit a greeting reaction — the sprite reads
  companionMuted non-reactively and its clock is paused while hidden,
  so a config-only unmute left it invisible until an unrelated
  re-render.
- /buddy name: strip ANSI escapes and control/format characters before
  saving, and cap by display width (stringWidth) instead of UTF-16
  length.
- CompanionSprite: track bubble age in sync-render state instead of an
  effect-updated ref, so a fresh reaction can't render pre-faded.
- companion_intro already keyed on name+species (prior commit); tests
  now pin exact faces for all seven heroes, separate idle/shoot pixel
  frame counts, and decode-guard the charCode species constants.
- CompanionActionFX tests: deterministic companion fixture via
  complete-config module mock; raw (untrimmed) output compared against
  a rendered-null baseline so a spurious blank FX row fails.
- companion.test: re-register the real config module in afterAll
  (mock.restore does not undo mock.module).
- Types: SPECIES_COLORS and FORM_FLAVOR are full Records (compile
  error on a colorless/flavorless future hero); dead RARITY_COLORS
  removed.

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

* test(buddy): regression coverage for bubble age reset on reaction change

Renders CompanionSprite against a fake shared clock: ages the first
bubble past the fade threshold, swaps the reaction WITHOUT advancing
the clock, and asserts the fresh bubble renders unfaded. Fading is
detected structurally (border and text collapse to one color when
fading) so the test is independent of the active theme's exact values.
Requested by CodeRabbit on PR #1972.

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

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-15 21:50:10 +08:00
0xfandomandGitHub 7b9e477519 fix(memdir): enforce entrypoint cap in bytes, not UTF-16 char length (#1918)
* fix(memdir): enforce entrypoint cap in bytes, not UTF-16 char length

truncateEntrypointContent bounds MEMORY.md/CLAUDE.md content with
MAX_ENTRYPOINT_BYTES (the field is byteCount, the flag wasByteTruncated,
and the warning renders it via formatFileSize) but measured and cut with
String.length, which counts UTF-16 code units. For multibyte content
(CJK/emoji at 3-4 bytes each) that undercounts by up to ~4x, so a file
that is tens of KB of real bytes but under 25,000 chars slips past the cap
entirely — reported as a fraction of its true size and passed through
uncapped, defeating the budget whose purpose is to bound context bloat.

Measure with Buffer.byteLength and perform the newline-boundary cut in byte
space (Buffer.lastIndexOf(0x0a) + subarray) so the cap actually bounds
bytes. Add regression coverage for a multibyte file over the byte cap but
under the char cap, and for small multibyte content left untouched.

* fix(memdir): keep the hard byte cut on a UTF-8 boundary

The no-newline fallback in truncateEntrypointContent hard-cut the UTF-8
buffer at MAX_ENTRYPOINT_BYTES. If that offset landed inside a multibyte
character, decoding with toString('utf8') emitted a U+FFFD replacement char,
so the body was no longer bounded by the cap — e.g. a single 30KB line of
CJK produced a 25,002-byte pre-warning body against a 25,000 cap.

Back up to the character's first byte (skip continuation bytes 0b10xxxxxx)
before slicing so the decoded body stays within the cap and contains no
replacement chars. Add a regression for the no-newline CJK case.

* docs(memdir): tell memory writers about the entrypoint byte cap

The save instructions only warned that MEMORY.md is truncated after 200
lines. Now that the 25KB byte cap is effective for multibyte content, a user
can stay well under 200 short CJK entries and still lose the tail of the
index without the writer knowing the byte cap exists. Update both the
private-memory guidance (buildMemoryLines) and the team-memory guidance
(teamMemPrompts) to state both limits — 200 lines or ~24KB, whichever comes
first.

* docs(memdir): state the byte cap in the extraction writer prompts

The background auto-only and combined memory extractors load MEMORY.md through
truncateEntrypointContent, which this branch makes enforce the 25KB byte cap for
multibyte content. Their save instructions still told the model only that lines
after 200 are truncated, so an extractor could satisfy the prompt with fewer
than 200 long entries and still lose the index tail on the next load. State both
the line and byte limits, matching the interactive private/team guidance.

* test(memdir): cover the combined line + byte truncation path

Add a case where content exceeds both MAX_ENTRYPOINT_LINES and
MAX_ENTRYPOINT_BYTES, so line truncation runs first and the byte cut then
applies to the already line-clipped result. Asserts the combined reason string,
the byte-bounded body, and no split-character replacement chars.
2026-07-15 20:51:55 +08:00
0xfandomandGitHub 9fc8806f8d fix(cache): use a monotonic clock for conversation-cache LRU recency (#1965)
evictLRU picks the entry with the smallest recorded access order, but that
order was set to accessOrder.size — which plateaus at ~maxSize once the cache is
full and drops on delete, so it is not monotonic. After the cache saturates,
every set/get stamps roughly the same value, and a freshly-inserted key can end
up ranked below entries a prior get already bumped. The next insert then evicts
the most-recently-touched key instead of the least-recently-used one, churning
the 50-entry session-history cache (sessionHistory.ts) into needless refetches.
Stamp recency from a monotonic counter that only ever increases.
2026-07-14 22:10:40 +08:00
Xiang HanandGitHub f961ae7432 fix: suspend footer work during ctrl-c feedback (#1963) 2026-07-14 22:07:31 +08:00
0xfandomandGitHub ed3927d0f5 fix(editor): account for NFC boundary composition in insert offset (#1954)
Cursor.modifyText builds newText from prefix + insert + suffix and hands it to
Cursor.fromText, which NFC-normalizes the whole string. But the new cursor
offset was computed as startOffset + insertString.normalize('NFC').length,
normalizing the insert in isolation. When the inserted text begins with a
combining mark that composes with the last character of the prefix (e.g. "e" +
U+0301 -> "é"), the normalized newText is one UTF-16 unit shorter than that
formula assumes, so the returned offset overshoots by one and the cursor lands
past the following text — the next keystroke then edits the wrong spot.

Measure the normalized prefix-plus-insert instead, so cross-boundary
composition is accounted for. Reduces to the previous behavior whenever no
boundary composition occurs (plain ASCII, astral emoji, insert at start).
2026-07-14 14:43:40 +08:00
c11c88bd91 perf: bound file IO concurrency (#1948)
* perf: bound file IO concurrency

* fix: honor abort and failure gates in bounded IO

* fix: preserve attachment timeout behavior

---------

Co-authored-by: Gautam Manchandani <gautammanch@Gautams-MacBook-Air.local>
2026-07-14 14:42:25 +08:00
BogdanandGitHub af0885d8ec fix(bg): revalidate process identity before signals (#1937)
* fix(bg): revalidate process identity before signals

* fix(bg): sanitize process identity probe failures

* fix(bg): skip signals for terminal sessions
2026-07-14 14:40:32 +08:00
JATMNandGitHub 6b2a1d8aef test(websearch): allow shared timeout test lock wait (#1959) 2026-07-14 14:39:00 +08:00
JATMNandGitHub 0dc622e129 test(cli): clean skills temp directories (#1946)
* test(cli): clean skills temp directories

* test(cli): handle stream cancellation errors
2026-07-13 16:21:05 +08:00
BogdanandGitHub 85cf2ac55a fix(update): avoid upstream package commands for custom builds (#1944)
* fix(update): avoid upstream package commands for custom builds

* test(update): cover runtime package identity
2026-07-13 16:20:30 +08:00
0xfandomandGitHub 2970b5fd5f fix(context): order pruned messages by envelope timestamp, not phantom field (#1934)
pruneByRelevance keyed recency scoring, the group tie-break, and the final
"restore chronological order" sort off message.message?.created_at. That
nested API-body field is never populated on our Message objects (nothing in
the tree assigns it), so every read was undefined and `?? 0` made all three
into no-ops: the recency bonus never fired, the tie-break never broke ties,
and the final sort left the list as [...recentMessages, ...olderGroups] —
the newest preserveRecent messages jumped ahead of older retained ones.

The chronological key actually lives on the Message envelope as `timestamp`
(an ISO-8601 string present on every variant). Add a messageTimeMs() helper
that parses it and route all three sites through it. This runs in the
auto-compaction path (autoCompact -> pruneByRelevance), so the reordered
list was being sent to the model. Add a regression using the real envelope
shape asserting retained messages stay in chronological order.
2026-07-13 16:19:33 +08:00
0xfandomandGitHub 9bf9926805 fix(mcp): preserve ":-" inside ${VAR:-default} default values (#1933)
expandEnvVarsInString split the ${VAR:-default} syntax with
varContent.split(':-', 2). The code comment says the limit is there to
"preserve :- in defaults", but JavaScript's String.split(sep, limit) caps
the array length and discards the remainder — it is not a maxsplit that
glues the tail back on. So a default that itself contains ':-' is
truncated at the first occurrence: ${VAR:-a:-b} expands to "a" instead of
bash's "a:-b".

Slice at the first ':-' with indexOf so any later ':-' stays in the
default. This runs over user .mcp.json command/args/env/url/headers values.
Add coverage for the ':-'-in-default case plus set/unset/empty-default and
missing-var paths.
2026-07-13 16:18:57 +08:00
JATMNandGitHub 2448ea9bfe fix(query): warn before repeated tool failures stop (#1927)
* fix(query): warn before repeated tool failures stop

* fix(query): preserve tool failure advisories

* fix(query): forward all tool failure advisories

* fix(query): harden tool failure advisories

* fix(query): preserve tool failure advisories

* fix(query): keep advisories one-shot

* fix(query): avoid duplicate advisories after compaction

* fix(query): compare advisory message IDs

* fix(query): cover advisory forwarding edges

* fix(query): retain advisories without tools
2026-07-12 22:27:48 +08:00
0xfandomandGitHub ffc5a6e8fc fix(gitdiff): apply the 1MB diff cap in bytes, not UTF-16 char length (#1932)
parseGitDiff skips per-file diffs larger than MAX_DIFF_SIZE_BYTES (1 MB) —
the constant is named _BYTES, its comment says "1 MB", and the function's
doc promises "Files >1MB: skipped entirely". But the guard measured
fileDiff.length, i.e. UTF-16 code units. Multibyte content (CJK/emoji at
3-4 bytes each) undercounts real size by up to ~4x, so a single-file diff
of several MB in bytes but under 1M code units slips past the cap and is
parsed and inserted into the result map — defeating the bound that keeps
huge single-file diffs from bloating memory/context in the diff viewer.

Measure with Buffer.byteLength. Add a regression: a diff under the cap in
code units but over it in bytes is now skipped.
2026-07-11 23:23:51 +08:00
0xfandomandGitHub 0369c86e3b fix(diff): number diff-snippet hunks by their new-file position (#1917)
getSnippetForTwoFileDiff builds each hunk's snippet from the new-file lines
(deletions are filtered out) but seeded addLineNumbers with the hunk's
oldStart. For the first hunk oldStart === newStart so labels are correct,
but any hunk following one that inserted or removed lines is mislabeled by
the net line delta of the earlier hunks — the shown numbers no longer match
the file the model is looking at.

Seed from newStart so the new-file content carries new-file line numbers.
Add a regression with a two-hunk diff whose second hunk shifts by +3.
2026-07-11 23:22:52 +08:00
Xiang HanandGitHub 4faf666cba fix: keep footer mounted across slash suggestions (#1943) 2026-07-11 23:22:23 +08:00
JATMNandGitHub 4f971a1316 fix(permissions): resolve relative worktree edit paths (#1930) 2026-07-10 16:35:57 +08:00
JATMNandGitHub 5918e330b2 fix(model): default NVIDIA NIM main loop model (#1928)
* fix(model): default NVIDIA NIM main loop model

* fix(cost): avoid default pricing for unknown models

* fix(model): use NIM descriptor default

* test(model): pin NIM descriptor fallback
2026-07-10 16:35:04 +08:00
Xiang HanandGitHub b588c26db2 fix(effort): keep effort indicator visible in prompt footer (#1919)
* fix: keep effort indicator visible

* fix: preserve footer status for effort fallback

* fix: simplify effort footer selection logic

* fix: harden effort footer fallback

* test: cover brief effort footer suppression

* fix: ignore empty footer notifications

* test: cover live effort footer updates

* fix: handle empty JSX footer notifications
2026-07-10 16:33:11 +08:00
JATMNandGitHub 9e06951e6e refactor(messages): extract content helpers (4 of 8) (#1901)
* refactor(messages): extract content helpers

* test(messages): cover extracted content helpers

* test(messages): statically import content helper

* test(compact): preserve assistant text helper semantics
2026-07-10 16:28:40 +08:00
JATMNandGitHub 64d164d207 fix(tools): keep HY3 tool schemas inline (#1923)
* fix(tools): keep HY3 tool schemas inline

* fix(hy3): parse documented tool wrapper

* fix(hy3): retain split wrapper openers

* fix(hy3): recover zero-argument tool calls

* test(hy3): restore fetch after XML stream tests

* fix(hy3): scope compatibility to Tencent
2026-07-10 09:39:25 +08:00
NikandGitHub 2259c809f7 fix(shim): don't infer Z.AI tool_stream for non-catalog GLM gateways (#1908)
The name-based shim matcher inferred the full Z.AI GLM contract —
including enableToolStreaming — for any glm-<n> model without a catalog
entry. tool_stream is a Z.AI-proprietary streaming extension, so serving
GLM through an arbitrary OpenAI-compatible gateway (e.g. NVIDIA NIM,
integrate.api.nvidia.com) made every request fail immediately with
400 Unsupported parameter(s): tool_stream.

Only a catalog entry may opt into tool_stream (Z.AI-contract gateways set
it explicitly via transportOverrides.openaiShim). Inferred GLM routes keep
the reasoning-shaping fields, which any GLM endpoint benefits from, but no
longer send tool_stream; without it tool calls are simply not streamed.
2026-07-10 09:38:32 +08:00
JATMNandGitHub 6d6b8de2ea refactor(messages): extract API cleanup helpers (8 of 8) (#1906)
* refactor(messages): extract API cleanup helpers

* fix(messages): strip nullable tool callers

* fix(messages): resolve extract review feedback
2026-07-10 09:37:54 +08:00
JATMNandGitHub 218064e376 fix(clipboard): fall back to image retrieval after Windows probe failure (#1922)
* fix(clipboard): run Windows Forms access in STA

* fix(clipboard): try image retrieval after Windows probe failure
2026-07-10 08:58:36 +08:00
0xfandomandGitHub 7995b9f492 fix(powershell): make CMDLET_PATH_CONFIG prototype-safe (#1913)
* fix(powershell): make CMDLET_PATH_CONFIG prototype-safe

CMDLET_PATH_CONFIG is a plain object literal keyed by the lowercased cmdlet
name, unlike its sibling maps CMDLET_ALLOWLIST (readOnlyValidation.ts) and
COMMON_ALIASES (parser.ts) which are Object.create(null) specifically to defend
against prototype-chain pollution. extractPathsFromCommand does
`CMDLET_PATH_CONFIG[resolveToCanonical(cmd.name)]` guarded only by
`if (!config)`. A command named 'constructor' or '__proto__' (both survive the
lowercasing) makes the lookup return an inherited Object.prototype member —
truthy — so the guard is bypassed and `[...config.knownSwitches]` throws
(spread of undefined), crashing path-constraint validation instead of treating
the name as an unknown non-path cmdlet. Give the map a null prototype like its
siblings so inherited-key lookups return undefined.

* test(powershell): assert set-content control resolves to ask decision

The control case previously only asserted checkPathConstraints did not
throw, so it would still pass if CMDLET_PATH_CONFIG stopped resolving its
own entries and set-content fell through as an unknown cmdlet. Assert the
returned behavior is 'ask' so the test proves the null-prototype map still
holds and classifies its own keys. Also reword the collision comment to
present constructor/__proto__ as representative reachable names rather than
the only ones.
2026-07-10 08:53:25 +08:00
JATMNandGitHub 07dd90db7b refactor(messages): extract plan mode rendering (7 of 8) (#1905)
* refactor(messages): extract plan mode rendering

* test(messages): cover plan mode reminder branches
2026-07-10 08:50:38 +08:00
0xfandomandGitHub 87c457c2fa fix(diff): count dropped lines correctly in truncated diff snippet (#1916)
getSnippetForTwoFileDiff caps the two-file diff snippet at
DIFF_SNIPPET_MAX_BYTES and appends a "[N lines truncated]" notice. When
the cut snaps back to a line boundary (the normal case), kept ends right
before the boundary newline, so full[cutoff] is that '\n'. Counting
newlines from kept.length already includes the boundary newline plus every
later one — which is exactly the number of dropped lines — so the
unconditional +1 double-counts it and the notice reports one line too many
(two if the snippet ends with a trailing newline).

Split the boundary and mid-line cases: at a boundary the count needs no
+1; only the mid-line cut (no newline within the cap) keeps a partial tail
line that must be added. Add a regression asserting kept + reported ==
total lines.
2026-07-09 22:49:47 +08:00
JATMNandGitHub eb5d719780 refactor(messages): extract basic factories (5 of 8) (#1902)
* refactor(messages): extract basic factories

* test(messages): expand factory parity coverage

* fix(messages): own factory synthetic constants

* test(compact): restore real message helpers per test

* fix(messages): preserve assistant tool scan behavior
2026-07-09 22:49:10 +08:00
0xfandomandGitHub eeed68f4fd feat(provider): add Cloudflare Workers AI integration (#1100) (#1178)
* feat(provider): add Cloudflare Workers AI integration

Adds Cloudflare Workers AI as a first-class OpenAI-compatible provider
preset, modeled on the Venice / Xiaomi MiMo descriptors.

- New `src/integrations/vendors/cloudflare.ts` descriptor:
  - `classification: 'openai-compatible'`
  - Default base URL with literal `<ACCOUNT_ID>` placeholder — users
    substitute via `/provider` baseUrl edit, same shape as the Azure
    OpenAI example already in `docs/advanced-setup.md`
  - `CLOUDFLARE_API_TOKEN` env, with `OPENAI_API_KEY` as fallback
  - `removeBodyFields: ['store']` since Workers AI rejects unknown
    OpenAI body fields (mirrors Mistral / Gemini / Cerebras strip)
  - Static catalog with current Workers AI chat models
    (`@cf/meta/llama-3.3-70b-instruct-fp8-fast`,
    `@cf/meta/llama-3.1-8b-instruct`,
    `@cf/deepseek-ai/deepseek-r1-distill-qwen-32b`,
    `@cf/qwen/qwen2.5-coder-32b-instruct`)
  - Validation routing on `api.cloudflare.com` /
    `gateway.ai.cloudflare.com` hosts so an env-pasted URL maps back
    to the preset
- Env mirror sites in `src/utils/providerProfiles.ts`: mirror api key
  into `CLOUDFLARE_API_TOKEN` when baseUrl contains a Cloudflare host
  (3 sites: same-env check, openAIProfileEnv build, applyEnv).
- `CLOUDFLARE_API_TOKEN` added to `PROFILE_ENV_KEYS` / `SECRET_ENV_KEYS` /
  `ProfileEnv` / `SecretValueSource` in `src/utils/providerProfile.ts`
  so the profile-clean and secret-redact paths know about it.
- `src/utils/providerFlag.ts` `--provider <name>` startup flag now
  detects a Cloudflare profile from `OPENAI_API_KEY ===
  CLOUDFLARE_API_TOKEN` (mirrors how the other host-key mirrors are
  reverse-mapped to their preset id).
- `bun run scripts/generate-integrations-artifacts.ts` regenerated
  `integrationArtifacts.generated.ts` to include the cloudflare preset
  + route + vendor.
- Tests: `compatibility.test.ts` PRESETS list, new
  `buildProfileSaveMessage` Cloudflare case in `provider.test.tsx`,
  new `applyProviderProfileToProcessEnv` Cloudflare case in
  `providerProfiles.test.ts`.
- Docs: README providers table row + `docs/advanced-setup.md` section
  matching the MiMo / Mistral entries.

- Dedicated AI Gateway integration with `gateway_id` URL templating.
  Today users can still paste a full Gateway URL into `OPENAI_BASE_URL`
  and the preset's `matchBaseUrlHosts` picks `gateway.ai.cloudflare.com`
  up.
- Dynamic `/models` discovery on the Groq #1143 / `mapModel` pattern —
  Cloudflare's `/v1/models` returns the runnable model list and the
  hybrid catalog path drops in cleanly. Left as a separate PR so this
  one stays a focused preset add.

Closes #1100

* fix(cloudflare): narrow route matching to api.cloudflare.com host

`gateway.ai.cloudflare.com` is the shared host for *all* Cloudflare AI
Gateway routes (Workers AI, Anthropic, OpenAI, etc.), so matching it to
the Workers AI preset applied Workers-AI runtime metadata and
credential precedence (CLOUDFLARE_API_TOKEN before OPENAI_API_KEY, body
'store' strip, max_tokens field) to other providers' Gateway URLs.
Drop the shared host from the match list; a dedicated AI Gateway
integration with path-aware routing is the right follow-up.

Refs #1100.

* fix(provider-manager): keep Codex OAuth after DeepSeek when cloudflare added

The picker hardcoded `options.splice(7, 0, …)` to drop the Codex OAuth
entry right after DeepSeek. Adding cloudflare to ORDERED_PROVIDER_PRESETS
bumped DeepSeek to index 7, so the splice now lands Codex OAuth *before*
DeepSeek and breaks the test fixture that drives navigateToPreset by
keypress count.

Switch to a dynamic `findIndex('deepseek') + 1` lookup so any future
preset inserted between Bankr and DeepSeek keeps the established
ordering. Fixture updated to mirror the new picker order.

Caught by CI on 12b3ff… smoke-and-tests: 8 ProviderManager tests
timing out because navigateToPreset overshot/undershot the target.

* fix(cloudflare): exclude the shared AI Gateway host from Cloudflare routing

The profile env/alignment/startup paths mirrored CLOUDFLARE_API_TOKEN whenever
the profile URL merely contained 'gateway.ai.cloudflare.com'. That host is the
shared AI Gateway for all Cloudflare AI routes (Workers AI, OpenAI, Anthropic,
...), so a profile retargeted to /openai or /anthropic Gateway URLs was wrongly
tied to the Cloudflare route and credential precedence.

Add isCloudflareBaseUrl (hostname === api.cloudflare.com, matching the Workers
AI host and the descriptor's matchBaseUrlHosts) and route all three sites
through it, consistent with isXaiBaseUrl/isFireworksBaseUrl. Also restore
CLOUDFLARE_API_TOKEN in the provider profile test cleanup keys.

* fix(cloudflare): don't seed the placeholder base URL from the CLI shortcut

`openclaude --provider cloudflare` fell through the generic OpenAI-compatible
branch and applied the descriptor default base URL verbatim — including the
unresolved `<ACCOUNT_ID>` placeholder — leaving the shortcut 'configured' with
an endpoint that cannot serve a request. Skip seeding any base URL that still
contains a `<...>` placeholder, so the user must supply a real account-scoped
URL (OPENAI_BASE_URL / `/provider` edit) first, matching how the wizard treats
placeholder endpoints.

* test(cloudflare): assert exact null fallback for AI Gateway routes

The shared AI Gateway URL assertions used `.not.toBe('cloudflare')`, which
would also pass for any other non-cloudflare return value. The intended
fallback is null, so assert `.toBe(null)` to lock the regression boundary.

* fix(cloudflare): gate profile token mirroring on base URL host only

applyProviderProfileToProcessEnv mirrored CLOUDFLARE_API_TOKEN whenever
route.routeId === 'cloudflare'. route comes from the saved profile.provider,
so that disjunct is always true for a cloudflare profile, including one
retargeted to the shared gateway.ai.cloudflare.com AI Gateway host. The
sibling sites (isProcessEnvAlignedWithProfile, buildOpenAICompatibleStartupEnv)
already key on isCloudflareBaseUrl only; align this site with them so a
shared-gateway profile no longer leaks the token or stays pinned to the
cloudflare route. Add a regression test for the gateway.ai.cloudflare.com case.

* chore(integrations): regenerate artifacts for the Cloudflare vendor

The rebase took main's generated artifacts at the conflict; regenerate so the
Cloudflare vendor descriptor is registered in VENDOR_DESCRIPTORS and the
manifest alongside the providers main added.

* fix(cloudflare): mirror CLOUDFLARE_API_TOKEN into the OpenAI-compatible auth path

The --provider cloudflare shortcut fell through to the generic
OpenAI-compatible default branch and never copied CLOUDFLARE_API_TOKEN
into OPENAI_API_KEY, so a user who only set the token sent an
unauthenticated request. Add a dedicated cloudflare case that mirrors the
token (and clears a stale generic key when absent), keeping the
placeholder-URL skip.

buildOpenAICompatibleStartupEnv also returned from its strict-env branch
before the fallback CLOUDFLARE_API_TOKEN mirror, so a keyed Cloudflare
profile persisted a startup env that omitted the token and re-detected
inconsistently after relaunch. Mirror it in the strict branch alongside
nearai/fireworks. Add regression coverage for both paths.

* fix(cloudflare): gate token mirroring on a real Cloudflare endpoint

The cloudflare shortcut copied CLOUDFLARE_API_TOKEN into the generic
OPENAI_API_KEY unconditionally. The descriptor default carries an
unresolved `<ACCOUNT_ID>` placeholder and is never seeded, so with
OPENAI_BASE_URL unset (or still pointing at a previous OpenAI-compatible
provider) the token would be attached to the wrong host. Gate the mirror
on isCloudflareBaseUrl(getConfiguredOpenAIBaseUrl()) — only seed
OPENAI_API_KEY once the configured base URL resolves to
api.cloudflare.com, otherwise fail fast and leave it unset. Add
regression coverage for the unconfigured, stale-host, and AI-Gateway-host
cases.

* fix(cloudflare): reject placeholder URL and keep the OPENAI_API_KEY fallback

The token mirror keyed on the api.cloudflare.com host only, so the literal
<ACCOUNT_ID> placeholder URL (same host) passed the gate and copied the
token onto a non-working endpoint. It also deleted any generic
OPENAI_API_KEY when no token was set, breaking the documented
compatibility fallback for users authenticating a real Workers AI URL with
OPENAI_API_KEY. Mirror only on a real (non-placeholder) Cloudflare
endpoint, and preserve an existing generic key there when no dedicated
token is present.

Refs #1100

* refactor(cloudflare): model Workers AI as a gateway, not a vendor

Cloudflare Workers AI is a hosted OpenAI-compatible inference endpoint
reached over the shared openai transport, so it belongs with the gateway
providers (atlas-cloud, groq, together, ...) rather than the transport
vendors. Move it to gateways/cloudflare.ts via defineGateway (category
hosted, vendorId openai), regenerate the integration artifacts, and
allowlist its provider-specific @cf/* catalog ids in the gateway
descriptor check (no shared cross-provider descriptor exists, same as
azure-deployment).

Refs #1100

* fix(cloudflare): key Workers AI detection on the account path, not the host

api.cloudflare.com also serves the general Cloudflare REST API, so matching the
whole host treated unrelated URLs (e.g. /client/v4/user/tokens/verify) as the
Workers AI route and mirrored CLOUDFLARE_API_TOKEN into OPENAI_API_KEY for them.

isCloudflareBaseUrl now requires the Workers AI path
/client/v4/accounts/<account_id>/ai/v1 with a real (non-placeholder) account id,
and resolveRouteIdFromBaseUrl guards its cloudflare hostname match through the
same predicate. Both route detection and token/profile mirroring key on the
actual Workers AI endpoint.

Adds same-host negative regressions (general REST path is not routed and does
not mirror the token; unresolved <ACCOUNT_ID> placeholder is excluded) and
asserts the Cloudflare Workers AI preset appears in the first-run picker.

* fix(cloudflare): honor the Workers AI path boundary in the profile-provider fallback

resolveActiveRouteIdFromEnv returned the saved active-profile provider's route
id before consulting its base URL. For a `cloudflare` profile that had been
retargeted to a non-Workers URL — the shared AI Gateway host, or a general
api.cloudflare.com REST path — this still resolved as `cloudflare`, so the
Workers AI shim config (removeBodyFields: ['store'], Cloudflare model metadata)
and CLOUDFLARE_API_TOKEN mirroring were applied to a generic endpoint, even
though resolveRouteIdFromBaseUrl already excludes those URLs.

Gate the profile-provider shortcut through profileRouteHonorsBaseUrlBoundary,
which requires the path-aware isCloudflareBaseUrl for the cloudflare route (all
other routes are host-scoped by resolveProfileRoute and unaffected). A retargeted
profile now falls through to the generic openai/custom resolution; a genuine
Workers AI profile base URL still resolves as cloudflare.

Adds regressions for both retarget cases (gateway host + REST path) and the
positive Workers AI profile case.

* fix(cloudflare): require HTTPS and honor the Workers AI path in validation

isCloudflareBaseUrl accepted any scheme, so http://api.cloudflare.com/
client/v4/accounts/<id>/ai/v1 resolved as the cloudflare route and mirrored
CLOUDFLARE_API_TOKEN into OPENAI_API_KEY over cleartext. Require url.protocol
=== 'https:'.

Startup validation selected the Cloudflare target on host match alone, so a
non-Workers path like /client/v4/user/tokens/verify demanded Workers AI auth
instead of falling back to generic OpenAI validation. Gate the cloudflare
target on isCloudflareBaseUrl(request.baseUrl), mirroring the runtime route
resolver's path boundary.

* test(cloudflare): lock non-Workers path token boundary; fix stale host-only comments

The apply/persist paths already gate CLOUDFLARE_API_TOKEN mirroring on the
isCloudflareBaseUrl path predicate, but had no coverage for a same-host
non-Workers path (api.cloudflare.com/client/v4/user/tokens/verify) and the
comments beside the mirroring sites still described a host-only boundary.

Add negative apply and persist regressions asserting the token is not mirrored
or persisted for that non-Workers URL, and update the comments to describe the
real Workers AI path predicate instead of host-only matching.

* fix(cloudflare): fall back to a generic route for retargeted profiles

resolveProfileCapabilityRouteId returned the cloudflare capability route id for
any saved cloudflare profile whose base URL no longer resolves — including one
retargeted to gateway.ai.cloudflare.com or another OpenAI-compatible host. That
stripped generic capabilities (apiFormat, custom auth/request headers) from
profile sanitize/apply even though the runtime resolver runs such a profile as
a generic OpenAI-compatible route. Mirror the same isCloudflareBaseUrl boundary:
keep the cloudflare route only for the real Workers AI URL (or the unset
descriptor default) and fall back to 'custom' otherwise. Regression asserts a
retargeted cloudflare profile preserves OPENAI_API_FORMAT.

* test(cloudflare): assert retargeted profile resolves to the custom route

Pin both resolveActiveRouteIdFromEnv assertions for a retargeted cloudflare
profile to .toBe('custom') instead of .not.toBe('cloudflare'), so the test
locks the intended generic OpenAI-compatible fallback rather than merely
excluding the cloudflare route.
2026-07-09 22:48:10 +08:00
0xfandomandGitHub 3f85b255dd fix(commands): escape named-argument names before building the regex (#1914)
substituteArguments builds a dynamic RegExp from each frontmatter-defined
argument name without escaping regex metacharacters:
new RegExp(`\$${name}(?![\[\w])`, 'g'). parseArgumentNames only rejects
empty and numeric-only names, so an author-defined name that contains a regex
special char reaches the constructor. A name with an unbalanced '(' / '[' (e.g.
'pattern)') throws a SyntaxError on every invocation of that skill/command, and
a name like 'a.' silently over-matches ('.' turns $ab into the arg value).
Escape the name with the existing escapeRegExp helper so it is matched
literally.

Consumers: src/skills/loadSkillsDir.ts and src/utils/plugins/loadPluginCommands.ts
feed frontmatter argument names into substituteArguments.
2026-07-09 09:17:55 +08:00
JATMNandGitHub 6602076b53 refactor(messages): extract system factories (6 of 8) (#1903)
* refactor(messages): extract system factories

* test(messages): cover system factory extraction
2026-07-09 09:15:48 +08:00
JATMNandGitHub a154f711da refactor(messages): extract normalization helpers (3 of 8) (#1900)
* refactor(messages): extract normalization helpers

* fix(messages): avoid discarded UUID allocation

* style(messages): align normalize re-export quotes
2026-07-09 09:13:42 +08:00
JATMNandGitHub fc568b9a34 refactor(messages): extract streaming helpers (2 of 8) (#1899)
* refactor(messages): extract streaming helpers

* fix(messages): remove streaming EOF whitespace

* chore(messages): clean streaming extraction imports
2026-07-09 09:12:39 +08:00
JATMNandGitHub e42aeb34f7 refactor(messages): extract tool pairing helpers (1 of 8) (#1898)
* refactor(messages): extract tool pairing helpers

* style(messages): align tool pairing re-export quotes
2026-07-09 09:11:41 +08:00
JATMNandGitHub e086e8c35a fix(safety): relax over-restrictive safety checks for benign coding tasks (#1897)
* fix(safety): relax over-restrictive safety checks for benign coding tasks (Fixes #1616)

Issue #1616 reports refusals for routine, benign coding tasks. Two layers caused this:

1. Model-level over-refusal: CYBER_RISK_INSTRUCTION and the 'ask before acting' guidance biased the model toward refusing normal work. Reworded to explicitly permit ordinary engineering tasks and dual-use/security-adjacent work in authorized contexts, and to ask a clarifying question rather than refuse when intent is ambiguous.

2. Application-level heuristics that become hard blocks in auto/YOLO/headless mode: the bash command-injection check, the broad DANGEROUS_FILES/DANGEROUS_DIRECTORIES auto-edit guard, and the auto-mode stripping of ordinary interpreter allow-rules (Bash(python:*), npm run:*, etc.).

Added an OPENCLAUDE_SAFETY_LEVEL knob (strict|balanced|permissive, default balanced). In 'permissive' the application-level heuristics above are relaxed while genuine Windows-path/symlink guards remain active. Default behavior is unchanged.

Validation:
- bun run typecheck: clean
- bun run build: succeeds
- bun test safetyLevel.test.ts, bashSecurity.safety.test.ts: pass
- bashSecurity.test.ts, filesystem.test.ts, permissionSetup.test.ts, security-hardening.test.ts: pass (no regressions)

* fix(safety): narrow permissive safety relaxations

* fix(safety): address review follow-ups

* fix(safety): address additional review findings

* refactor(permissions): share rule normalization
2026-07-09 09:07:50 +08:00
JATMNandGitHub de9729500b fix(nvidia-nim): enable reasoning template kwargs (#1893)
* fix(nvidia-nim): enable reasoning template kwargs

* fix(nim): preserve explicit provider selections

* fix(nim): limit env-only startup precedence
2026-07-09 09:05:50 +08:00
0xfandomandGitHub cde6e090d3 fix(read): report zero lines for an empty file (#1881)
* fix(read): report zero lines for an empty file

readFileInRangeFast ran the final-fragment block unconditionally, so a 0-byte
file pushed one phantom empty line and returned totalLines: 1. FileReadTool
picks its empty-file warning on totalLines === 0, so an empty file instead hit
the else branch and emitted the wrong message: "the file exists but is shorter
than the provided offset (1). The file has 1 lines." — leaving the dedicated
"the contents are empty" message unreachable. Short-circuit empty input right
after the BOM strip to return totalLines: 0. Trailing-newline counting (split
semantics) is unchanged. Regression covers the empty file plus one-line and
two-line no-trailing-newline controls.

* test(read): clean up temp dirs created by readFileInRange tests

Track each mkdtempSync directory and remove it in afterEach so the new tests
don't leak openclaude-readrange-* dirs under the OS temp path across runs.
2026-07-09 09:05:03 +08:00
2047fb250f fix(query-guard): exclude human-interaction wait from session timeout (#1879)
* fix(query-guard): exclude human-interaction wait from session timeout

The session watchdog (idle 5min / hard-max 30min) counted time spent
blocked on a human decision — permission prompts, AskUserQuestion,
plan-mode selection — as stuck work and force-ended the query when a
user took too long to choose. The watchdog is a fork-local addition
(upstream Claude Code has no session-level query timeout) that never
excluded human think-time.

Add a reference-counted QueryGuard.beginUserInteraction() that freezes
the watchdog while blocked on the user and, on resume, shifts the
hard-max and lease deadlines forward by the paused duration and restarts
the idle window. Wire it through queryActivity and wrap the permission
resolution in toolExecution so every interactive 'ask' is covered.

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

* fix(query-guard): scope watchdog suspension to interactive permission wait

Address review feedback on #1879:
- Move the suspend/resume out of checkPermissionsAndCallTool's wrapper around
  the whole permission resolution and into interactiveHandler, so it covers
  only the window that truly blocks on user input. Non-human async work (e.g.
  the classifier in hasPermissionsToUseTool) stays watched and a genuinely
  stuck check can still fire the watchdog.
- Reset _suspendedAt alongside _suspendCount in end()/forceEnd() to keep the
  'not suspended => suspendedAt=0' invariant.

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

* test(query-guard): cover watchdog suspend/resume wiring in interactiveHandler

Address CodeRabbit feedback on #1879: add a focused test for the interactive
permission path asserting beginUserInteraction runs once and the resume fn
fires exactly once per terminal resolution (allow/reject/abort), and only once
when two paths race, so a future resolution path bypassing resolveOnce fails
the test instead of silently reintroducing the timeout bug.

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

* fix(query-guard): resume watchdog on claim, not on resolve

Address maintainer review on #1879 (P2a/P2b): resume the query watchdog the
moment a permission decision is claimed, not when resolveOnce() completes.
Every terminal path claims first, so resuming there means (a) post-decision
async work (handleUserAllow -> persistPermissions) runs watched again once the
human has decided, and (b) an exception in that work can no longer strand the
watchdog suspended for the rest of the turn -- the likely cause of the CI
smoke-and-tests full-suite hang. Resume stays idempotent (a resolveOnce safety
net remains). Adds tests: resume-before-await and resume-on-throw.

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

* fix(test): stop QueryGuard suspension tests leaking fake timers

The beginUserInteraction describe block was accidentally nested inside the
QueryLifecycleOperationTracker describe, which has no afterEach — so its
vi.useFakeTimers() was never restored. The leaked fake clock froze setTimeout
in whatever test file ran next, hanging the full single-concurrency suite: CI
smoke-and-tests ran ~29m and was cancelled, stalling at src/utils/cwd.test.ts.
Moved the block into the QueryGuard describe whose afterEach restores real
timers.

Verified locally: QueryGuard.test.ts + cwd.test.ts, and the full CI-order utils
batch that previously hung >115s, now pass in <1s.

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

* fix(query-guard): call watchdog resume at most once (exactly-once contract)

Address Copilot review on #1879: beginUserInteraction()'s resume fn is
documented as call-exactly-once, but it was invoked from both claim() and the
resolveOnce safety net. Wrap it in a local idempotent helper so the underlying
QueryActivity resume runs at most once, rather than relying on QueryGuard's own
idempotence (other implementations may not have it). The test mock is now a
plain spy, so a double-call would fail the exactly-once assertions.

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

* fix(query-guard): resume watchdog on external aborts

An abort that bypasses the permission dialog callbacks — a bridge interrupt, or
the REPL's now-priority/backgrounding paths calling abortController.abort()
while a prompt is open — never runs claim()/resolveOnce(), so the captured
resume was never called and QueryGuard stayed suspended. The turn could then
hang on the unresolved permission promise with the watchdog disabled, unable to
recover. Resume on the abort signal (idempotent, once) so the watchdog always
recovers. Adds tests: abort-after-open, already-aborted, and no-double-resume.

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

* fix(query-guard): resume watchdog if dialog setup throws synchronously

Address review [P3]: beginUserInteraction() suspends QueryGuard before the
handler pushes the dialog and before any claim(). A synchronous throw during
setup (e.g. pushToQueue or bridge wiring) would exit the handler before any
claim()/resolveOnce() runs, leaving the watchdog suspended for the rest of the
turn. Wrap the setup path in try/catch that resumes then rethrows, so the error
still propagates. Complements the abort-signal path (async cancel) — the two
cover distinct exit modes and resume stays idempotent.

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

* fix(query-guard): resolve externally-aborted prompts immediately

Address review [P2]: on an abort that bypasses the dialog callbacks (bridge
interrupt, REPL now-priority/backgrounding), the abort handler only resumed the
watchdog. That left the permission promise unresolved while resetting the idle
deadline, so an already-open prompt kept queryGuard.isActive true for a full
idle timeout before cleanup, blocking the queued interrupt/background work. The
abort path now claims and resolves/cancels the pending permission (claim() also
resumes the watchdog), so the awaiter unblocks immediately. Bridge/channel
blocks still clean up their own subscriptions on abort.

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

* fix(query-guard): stop setup on immediate abort, detach abort listener

Address CodeRabbit review:
- When abortSignal is already aborted before the dialog is shown, cancel and
  return so setup never enqueues a prompt (pushToQueue / bridge / channel / hook)
  that is immediately stale.
- Detach the external abort listener on any normal terminal resolution so a
  resolved prompt doesn't retain a closure on the query-scoped abort signal.
- Test: assert pushToQueue is not called for a pre-aborted prompt.

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

* fix(query-guard): dequeue prompt on external abort; indent guarded setup

Address review:
- [P2] External abort (now-priority/bridge interrupt, backgrounding) resolved
  the permission but never removed the queued ToolUseConfirm. Since the REPL
  derives focusedInputDialog from toolUseConfirmQueue[0] and only UI actions
  call onDone, the stale dialog could stay focused after the aborted turn and
  interfere with foreground work. onExternalAbort now calls ctx.removeFromQueue()
  (no-op on the immediate-abort path, before the dialog is pushed).
- [P3] Indent the setup body inside the try added for synchronous-throw safety,
  matching the surrounding two-space block style so the catch scope is visible.

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

* fix(query-guard): clean up partial setup in the catch path

Address CodeRabbit review: if setup throws after the abort listener is
registered or after pushToQueue() succeeds, the catch now detaches the listener
and dequeues the prompt before rethrowing, so failed setup can't leave stale
UI/listeners behind. Moved removeExternalAbortListener out of the try so catch
can reach it. Trim over-explanatory comments.

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

* fix(query-guard): cancel bridge/channel prompts on external abort

Address review: onExternalAbort resolved/dequeued the local prompt but skipped
the bridgeCallbacks.cancelRequest(bridgeRequestId) and channelUnsubscribe()
cleanup the normal allow/reject/abort paths do. With the bridge response handler
unsubscribed by the abort listener, the remote UI could keep showing a stale
prompt whose reply is ignored. Moved onExternalAbort below the
bridgeRequestId/channelUnsubscribe declarations (so the immediate-abort branch
can reach them without a TDZ) and mirror the local cleanup there.

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

* fix(query-guard): keep lease deadlines active during human waits

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 09:03:51 +08:00
0xfandomandGitHub 06e0ae6e0b feat(settings): per-model context_window and max_output_tokens overrides (#1234)
* feat(settings): per-model context_window and max_output_tokens overrides

Adds a `modelLimits` settings.json map so users can declare context window
and max output tokens for OpenAI-compatible models that are not in the
built-in catalog. Resolution order is env var → settings → catalog, so the
existing CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS / _MAX_OUTPUT_TOKENS env vars
keep priority. Keys are matched exactly, then by prefix, with optional
"<host>:<model>" host-qualified forms.

Example:
  "modelLimits": {
    "qwen3.6-plus": { "contextWindow": 1048576, "maxOutputTokens": 32768 }
  }

Closes #478

* fix(model-limits): apply settings overrides in resolveModelRuntimeLimits

The settings.json `modelLimits` fallback was only wired into the scalar
getOpenAIContextWindow / getOpenAIMaxOutputTokens helpers. The runtime
resolution path (resolveModelRuntimeLimits) instead consumes the *Matches
variants, which only returned env-var matches — so a configured modelLimits
override never reached the model whose limits are actually resolved at
runtime, contrary to the documented env → settings → catalog order.

Fold the settings lookup into getOpenAIContextWindowMatches /
getOpenAIMaxOutputTokenMatches as a dedicated `settings` field, and insert it
between the exact env override and the built-in catalog in
resolveModelRuntimeLimits. Declare `modelLimits` on GlobalConfig so the lookup
is typed. Add an integration test that drives resolveModelRuntimeLimits and
asserts settings resolve for exact, host-qualified and prefix keys, and that an
env override still wins.

* fix(model-limits): resolve modelLimits from settings.json, not global config

readSettingsLimits read getGlobalConfig().modelLimits, which is ~/.openclaude.json
— a different file from the settings.json the feature is documented and schema'd
for (SettingsSchema in settings/types.ts). A user adding modelLimits to
settings.json saw no effect. Read it from getInitialSettings() (the merged
settings snapshot, session-cached) instead, and drop the now-unused modelLimits
field from GlobalConfig so the override lives in one place. Rewire both test
suites to a gated-passthrough getInitialSettings mock.

* test(model-limits): capture real settings module at load, not in beforeEach

The settings modelLimits suites re-imported the real settings module with
a cold dynamic import inside beforeEach. That import sat right at Bun's
default 5s hook timeout and intermittently failed the first test. Capture
the genuine module once via a top-level query-string-busted static import
so the cost moves to module load (no hook timeout) and the gated
passthrough still bypasses other suites' settings.js mocks. Also correct
the integration-test comment to name the helpers the runtime path
actually calls (getOpenAIContextWindowMatches / getOpenAIMaxOutputTokenMatches).

* fix(model-limits): keep env-prefix override above settings in runtime resolver

resolveModelRuntimeLimits ordered the settings `modelLimits` value above
the env-prefix match, so a broad env-prefix override (e.g.
`{"my-custom":N}`) was silently overtaken by a more specific settings
entry (`my-custom-deployment`). The scalar getOpenAIContextWindow treats
env (exact ?? prefix) as strictly higher priority than settings; mirror
that in the runtime resolver: env.exact, then env.prefix, then settings,
then catalog/cache/descriptor. Regression covers both contextWindow and
maxOutputTokens.

* docs(model-limits): document the settings.json modelLimits override

Add a user-facing section near the env-var overrides covering the
modelLimits map, JSON shape, exact/prefix/host-qualified key matching, and
the env > settings > catalog > descriptor precedence.

* fix(model-limits): keep catalog above env-prefix in runtime precedence

The previous reorder put the env-prefix match above the catalog value,
which broke the existing invariant that a `:cloud` catalog variant takes
its known catalog limit rather than inheriting a broad base-model env
prefix (deepseek-v4-pro:cloud regressed to 262144). Correct order:
exact env -> catalog/cache -> env prefix -> settings -> descriptor. This
still keeps settings strictly below env-prefix (the original drift fix)
while preserving catalog precedence over prefix. Docs precedence updated
to match.

* docs(advanced-setup): document CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS env var

The modelLimits section referenced the max-output env var but the
Environment Variables table only listed the context-window one, leaving
output-only configuration undocumented. Add the matching table row.

Refs #478

* docs(model): align openaiContextWindows precedence comments with the resolver

The module header and the OpenALimitOverrideMatches.settings comment claimed a
resolution order of "env → settings → catalog", which contradicts
resolveModelRuntimeLimits (exact env → catalog/discovery cache → prefix env →
settings modelLimits → descriptor default). Since this module only produces the
override candidates and does not own the precedence, narrow the comments to say
so and point at runtimeMetadata.ts as the authoritative chain, so this
precedence-sensitive code isn't misread when touched again.

* fix(model): rank host-qualified modelLimits keys above bare model keys

lookupByModel grouped all exact matches (host-qualified and bare) ahead of all
prefix matches, so a bare exact key like `qwen3.6-plus` beat a host-qualified
prefix like `openrouter.ai:qwen3`. That defeated the advertised per-endpoint
disambiguation for versioned model families. A host-qualified key is strictly
more specific than a bare one, so rank both host-qualified forms (exact and
prefix) in the high-priority tier ahead of the bare exact match, leaving only
the bare prefix in the low-priority tier.

* fix(model): keep an exact modelLimits key ahead of any host-qualified prefix

The previous commit ranked host-qualified PREFIX matches above bare exact
matches, which broke the deliberate precedence in context.test.ts: an exact
`gpt-4o` limit was overridden by an unrelated `api.foo.com:gpt-4` prefix that
only matches a shorter, different model name.

Restore the tiering so an exact match (host-qualified or bare) always beats a
prefix, and a host-qualified key beats a bare key WITHIN the same match kind.
The supported way to set a different limit for the same model per endpoint is a
host-qualified EXACT key. Narrow the regression + comment to that behavior.

* docs(model): align modelLimits matching wording with exact-over-prefix rule

Narrow the advanced-setup wording so a host-qualified key only wins over a bare
key within the same match kind (a bare exact key still beats a host-qualified
prefix), matching lookupByModel's exact ?? prefix behavior; per-endpoint limits
for the same model need host-qualified exact keys. Also note modelLimits as part
of the documented user-override layer in the integration add-model and
common-pitfalls guides.

* docs(model): clarify modelLimits host-port key and catalog/cache precedence

Spell out that the host-qualified key uses new URL(baseUrl).host — including the
port when present (localhost:4000:my-model, not localhost:my-model) — and split
the precedence line so the built-in catalog is shown as checked before the
discovery-cache value, matching resolveModelRuntimeLimits.
2026-07-09 06:10:49 +08:00
0xfandomandGitHub de751f369c fix(editor): guard editor-override lookup against prototype keys (#1915)
editFileInEditor resolved the editor command with EDITOR_OVERRIDES[editor] ??
editor, where editor comes from $VISUAL / $EDITOR (arbitrary strings). For a
name that collides with an Object.prototype member — constructor, __proto__,
hasOwnProperty, toString — the bare lookup returns the inherited member (a
function / Object.prototype), which is non-nullish, so the '?? editor' fallback
is defeated and editorCommand becomes a stringified function rather than the
literal editor name; execSync then runs a corrupted command instead of the
editor the user named. Extract resolveEditorCommand and gate the lookup on
Object.hasOwn so unknown/proto names fall through to the literal name.
2026-07-08 23:01:37 +08:00
BogdanandGitHub ae9a765fb5 fix(env): align WebSearch and Ollama env docs (#1904) 2026-07-08 22:59:21 +08:00
3b41cf3adb fix(command-semantics): cover remaining linter runner exits (#1700)
* fix(command-semantics): cover remaining linter runner exits

* fix(command-semantics): handle env prefixes and PowerShell chains

* fix(command-semantics): preserve runner and pipeline failures

* fix(command-semantics): narrow setup and runner failure guards

* fix(command-semantics): catch real setup failure stderr

* fix(command-semantics): preserve setup failures with output

* fix(command-semantics): parse inline env split strings

* fix(command-semantics): inspect Bash merged failure output

* fix(command-semantics): align PowerShell failure parsing

* test(web-search): stabilize Brave timeout assertion

* fix(command-semantics): cover package scripts and wrapper failures

* fix(command-semantics): handle script run forms and PS call operator

* fix(command-semantics): cover package prefixes and npm errors

* fix(command-semantics): stabilize brave timeout and ps flags

* test(websearch): assert brave timeout aborts fetch signal

* fix(command-semantics): handle tsc diagnostic exit 1

* fix(command-semantics): preserve script diagnostics

* fix(command-semantics): guard silent skipped diagnostics

* fix(command-semantics): tighten setup failure guards

---------

Co-authored-by: jatmn <the@jat.mn>
2026-07-08 11:43:50 +08:00
9a53290588 feat(aimlapi): add guided top-up and key provisioning (#1886)
* fix(aimlapi): send valid rebate partner id (part_62yQ…) instead of literal 'Gitlawb'

* feat(aimlapi): add guided top-up and key provisioning

* fix: restore accidentally removed OpenGateway badge

* fix(aimlapi): restore preset order and harden topup polling/logging

* fix(aimlapi): validate --method choices instead of silently defaulting to card

* feat(aimlapi): guided top-up and API key provisioning

* fix(aimlapi): point non-interactive credential error at existing flags

* docs(aimlapi): document guided top-up alongside the existing-key path

---------

Co-authored-by: Lookoff123 <bataryshkinairina@gmail.com>
2026-07-08 09:17:17 +08:00
BogdanandGitHub e204d5ad36 feat(doctor): add WebSearch backend diagnostics (#1884)
* feat(doctor): add WebSearch backend diagnostics

* fix(doctor): tighten Firecrawl cloud URL diagnostics

* fix(firecrawl): align cloud URL detection

* test(websearch): stabilize Brave timeout assertion

* fix(firecrawl): handle bare cloud host casing

* fix(doctor): align WebSearch auto diagnostics with fallback

* fix(doctor): align custom preset diagnostics
2026-07-08 08:42:34 +08:00
780f703747 fix(installer): gate native-binary install behind NATIVE_PACKAGE_URL (#1838)
* fix(installer): gate native-binary install behind NATIVE_PACKAGE_URL

openclaude install inherited the upstream native installer, which downloads
the first-party Claude Code binary from the GCS bucket, symlinks
~/.local/bin/openclaude to it, and uninstalls the npm package the user is
running. Gate every native-installer surface behind hasNativeDistribution()
so npm-only builds never touch the native path; setting NATIVE_PACKAGE_URL
at build time re-enables it unchanged.

Also gate background cleanupOldVersions(): the versions/staging/locks
directories under ~/.local/share/claude (etc.) are shared with a coexisting
first-party native Claude Code install, and the protection logic only
recognizes our own launcher symlink — an npm-only build kept only the
newest VERSION_RETENTION_COUNT binaries and could delete the version a
user's pinned `claude` launcher still points to.

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

* fix(installer): keep npm-only guidance on npm paths

* test(installer): share macro mock helper

* fix(installer): clean stale native launcher in npm fallback

* fix(update): clean stale native launcher in slash update

* test(update): reuse shared macro helper

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
Co-authored-by: jatmn <the@jat.mn>
2026-07-08 08:42:01 +08:00
fb1137275a feat: add ultrathink keyword detection and ultracode effort level (#1551) (#1630)
* feat: add ultrathink keyword detection and ultracode effort level

Rebased onto current main so the diff contains only these changes — #1780's
model-level effort routing now comes from main rather than being duplicated.

- ultrathink: a `\bultrathink\b` keyword in a prompt injects a high-effort
  reminder, gated behind the isUltrathinkEnabled() rollout flag.
- ultracode: a new session-only EffortLevel that maps to xhigh (or high) on the
  wire and grants a standing multi-agent orchestration permission. First-party
  only, suppressed under a per-agent providerOverride, and gated to
  xhigh-capable models. Honors CLAUDE_CODE_EFFORT_LEVEL precedence across the API
  path, the permission attachment, and the display surfaces; rejected from every
  agent-definition input (markdown/skill/plugin frontmatter, SDK, and JSON).

Closes #1551

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(effort): clamp ultracode display availability

* fix(effort): report effective effort overrides

* test(model): avoid catalog-dependent effort label

* fix(model): resolve current effort against session model

* fix(spinner): resolve effort suffix against session model

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: jatmn <the@jat.mn>
2026-07-08 08:41:10 +08:00
0xfandomandGitHub aac2d8cdc8 fix(openai-shim): guard tool-arg field lookup against prototype keys (#1880)
STRING_ARGUMENT_TOOL_FIELDS is a plain-object lookup table keyed by a
provider-supplied tool-call name. hasToolFieldMapping used `name in table` and
getPlainStringToolArgumentField used a bare `table[name]` lookup, both of which
resolve inherited Object.prototype members. A tool call whose function.name is
'constructor', 'toString', '__proto__', etc. therefore reported a field mapping
(the Object constructor function), which is truthy, so the `?? null` fallback
never fired and normalizeToolArguments wrapped a JSON-encoded string argument
into a garbage-keyed object ({ 'function Object() { [native code] }': value })
instead of passing it through. Gate both lookups on Object.hasOwn, matching the
codebase convention for provider-keyed maps.

Consumers: src/services/api/openaiShim.ts (normalizeToolArguments /
hasToolFieldMapping over decoded tool_calls[i].function.name).
2026-07-07 22:28:47 +08:00
NikandGitHub 2f98208eaf fix(command-semantics): treat linter exit 1 as violations, not an error (#1846)
* fix(command-semantics): treat linter exit 1 as violations, not an error

Linters and formatters use exit code 1 to mean "violations found", not a
crash. commandSemantics fell back to DEFAULT_SEMANTIC for ruff/eslint, so a
run that merely reported lint findings was flagged isError: true and the
model retried the same command up to 3 times before giving up (observed on
Windows with `uvx ruff check --fix`).

Add a LINT_SEMANTIC (exit 1 = violations found, 2+ = real error, mirroring
the existing grep/diff pattern) for ruff and eslint, in both the Bash and
PowerShell tables. Wrapper runners (uvx, npx) inherit the wrapped tool's
semantics only when it resolves to a recognized command — an unrecognized
wrapped tool still falls back to the default, so `uvx <arbitrary>` is not
blanket-treated as non-error.

Adds coverage for ruff/eslint exit codes and the uvx/npx unwrap (including
the unknown-wrapper fallback) in both Bash and PowerShell suites.

* fix(command-semantics): normalize path-prefixed and quoted Bash linters

extractBaseCommand returned the raw first token, so path-prefixed or quoted
invocations (./node_modules/.bin/eslint, "ruff", /usr/bin/uvx ruff, npx
./node_modules/.bin/eslint) fell through to default exit-code semantics and a
linter's exit 1 was mis-reported as an error. Normalize the base and wrapped
command names (strip surrounding quotes and any path prefix) like the
PowerShell implementation does, and match the wrapper by its normalized name.
Adds regression coverage for path-prefixed and quoted linter/wrapper commands.

* fix(command-semantics): normalize Windows .cmd/.bat/.ps1 shims on PowerShell path

extractBaseCommand only stripped .exe, so npm-installed tools and wrappers
invoked via their Windows .cmd shims (eslint.cmd, npx.cmd,
.\node_modules\.bin\eslint.cmd) fell back to DEFAULT_SEMANTIC and reported
exit 1 as an error, regressing the lint-exit-code fix on the PowerShell path.
Broaden the suffix strip to the common PATHEXT executable/shim extensions
(.exe/.cmd/.bat/.ps1) so direct and wrapped .cmd invocations resolve to the
tool/wrapper name. Adds regression coverage for direct, path-prefixed, and
wrapped .cmd forms.
2026-07-07 22:16:49 +08:00
5105dff5f4 fix(config): recover from a healthy backup when the global config is corrupt (#1819)
* fix(config): recover from a healthy backup when the global config is corrupt

A present-but-corrupt ~/.openclaude.json took getConfig down the
ConfigParseError path, which reset to defaults (silently discarding the
user's settings) or re-threw, even though healthy timestamped backups exist
in ~/.claude/backups. getConfig only consulted a backup on ENOENT, and then
only to print a manual 'cp' hint. Now a corrupt parse first tries to recover
the most recent backup that still parses (merged over defaults) before doing
anything destructive, so a one-off bad write no longer wipes config or
crashes startup. Falls back to the existing defaults path when no backup is
usable. Adds direct unit tests for the recovery helper.

Closes #1807

* fix(config): iterate backups on recovery and skip rotating a corrupt file

Address review on #1819:

- recoverConfigFromBackup only tried the single newest backup, so a corrupt
  newest .backup left startup on the corrupted-config fallback even when an
  older healthy snapshot existed. Add listBackupsNewestFirst and iterate
  candidates newest-first, recovering from the first that parses. Added a
  newest-corrupt/older-healthy regression test.

- saveGlobalConfig copied the live file into the backup rotation before
  writing. After an in-memory recovery the on-disk file is still corrupt, so
  that copy poisoned the rotation with the same bad content. Only rotate the
  current file into backups when it parses.

* fix(config): never prune backups while the live config is corrupt

Address review: the MAX_BACKUPS cleanup ran unconditionally, so when the live
config is corrupt and only an older snapshot is healthy, startup's
runMigrations() -> saveGlobalConfig() could unlink that last usable backup
before the recovered config is durably written (#1807).

Extract the prune decision into a pure, exported selectBackupsToPrune() that
returns [] whenever the live config fails to parse, and gate the cleanup loop
on it. Hoist the parse check so both the backup-copy and the prune paths share
it.

Add production-path coverage: getConfig recovery via _getConfigForTesting
(corrupt live config -> healthy backup, and newest-corrupt -> older-healthy),
plus direct selectBackupsToPrune tests (corrupt -> prunes nothing; healthy ->
keeps newest maxBackups).

* fix(config): skip valid-but-non-object backups during recovery

recoverConfigFromBackup() returned the first backup that parsed, even
when the parsed value was not a config object. A newest backup holding
valid JSON like null, [], or a bare string spread into bare defaults or
index/char keys and stopped, discarding the older healthy snapshots the
#1807 recovery is meant to fall through to. Guard that parsedBackup is a
non-null, non-array object before returning; otherwise continue to the
next older backup.

Adds a regression test: a valid-but-unusable newest backup (null) is
skipped in favor of an older healthy one.

* fix(config): recover global config from legacy backups and self-heal corrupt startup

Two gaps in the #1807 recovery path surfaced in review:

- listBackupsNewestFirst filtered only on the active basename, so once the
  global config is .openclaude.json it never tried the pre-rename
  .claude.json.backup.* snapshots that #1807 reports as the only surviving
  clean source. Recover the global config from the legacy basename too, and
  order backups by their .backup.<ts> timestamp so current and legacy
  snapshots interleave by recency instead of grouping by filename.
- enableConfigs validated the global config with the throwing mode, so a
  corrupt config with no usable backup rethrew ConfigParseError through
  startup and locked users out on every launch. Drop the throwing mode so the
  corrupt-file/default fallback runs and startup self-heals.

Adds regression coverage: legacy-basename recovery plus a scoping guard, and
an enableConfigs no-crash test for the unrecoverable corrupt-config case.

* test(config): re-register real env module after startup validation test

The enableConfigs (#1807) regression test installs mock.module('./env.js', ...)
so getGlobalClaudeFile() points at the virtual config path. Bun's mock.module()
is process-global and is not undone by mock.restore(), so the teardown that only
reset the fs implementation left later same-process tests importing ./env.js on
the virtual path. Capture the real env module once in beforeAll and re-register
it in afterEach alongside the fs reset, matching the established restore pattern
in user.test.ts/effort.test.ts.

---------

Co-authored-by: Pablosinyores <nikhilbajaj0182@gmail.com>
2026-07-07 22:15:47 +08:00
NikandGitHub f7d472e826 fix(provider): add Use Anthropic option to switch back from third-party profiles (#1429)
* fix(provider): add Use Anthropic option to switch back from third-party profiles

The /provider menu offered no way back to built-in Anthropic once any
third-party provider profile was active: getActiveProviderProfile falls
back to profiles[0] when activeProviderProfileId is unset, so clearing
the active id still re-selected a saved profile. Users had to hand-edit
~/.openclaude.json and restart.

Add an explicit ANTHROPIC_DEFAULT_PROFILE_ID sentinel that getActive-
ProviderProfile resolves to undefined (Anthropic) instead of profiles[0],
and a clearActiveProviderProfile() that records the sentinel, clears the
managed provider env in-session, and removes the startup profile mirror.
Surface it as a 'Use Anthropic (built-in)' choice in /provider, shown
whenever the current provider is not Anthropic. Saved profiles are kept
for re-selection; the switch takes effect without a restart.

Fixes #1426

* fix(provider): wire Use Anthropic into live ProviderManager and keep the sentinel

Addresses review feedback on #1429:

- The "Use Anthropic (built-in)" option now lives in the live ProviderManager
  "Set active provider" flow (the wizard path is test-only). It is offered only
  when a third-party profile or GitHub Models is currently active, and routes
  through clearActiveProviderProfile() + resets the session model to the
  built-in Anthropic default so the switch takes effect without a restart.

- Teach the add/update/delete fallbacks that ANTHROPIC_DEFAULT_PROFILE_ID is a
  valid active state. Previously, adding a profile with makeActive:false,
  updating any profile, or deleting an inactive profile while on built-in
  Anthropic would silently reactivate profiles[0], switching the user back to a
  third-party provider. The delete path also no longer resolves the sentinel to
  profiles[0] when re-applying env.

Added regression tests covering the add/update/delete sentinel-preservation
paths.

* fix(provider): clear startup provider overrides when switching back to Anthropic

The /provider Anthropic activation branch cleared the managed session env
and the startup profile file but left the startup provider override in user
settings intact, so a restart would replay the third-party provider. Clear it
the same way the saved-profile and GitHub paths do, surfacing any cleanup
failure as a warning. Also assert the managed provider env is removed in the
clearActiveProviderProfile session-env test.

* fix(provider): clear startup overrides from /provider Anthropic branch; honor makeActive:false for implicit-active profiles

Addresses two review findings on #1429:

- The /provider 'Use Anthropic (built-in)' branch only called
  clearActiveProviderProfile(), so settings-backed startup overrides
  (CLAUDE_CODE_USE_OPENAI / OPENAI_BASE_URL / API key) survived and
  re-selected the third-party provider on restart. It now also calls
  clearStartupProviderOverrides() and surfaces a cleanup warning in the
  onDone message instead of reporting unconditional success, mirroring the
  ProviderManager Anthropic branch.

- addProviderProfile(makeActive:false) still promoted the new profile when
  activeProviderProfileId was unset but saved profiles existed, because
  getActiveProviderProfile() implicitly resolves that state to the first
  profile while the old ternary treated !currentActive as 'no active'.
  Resolve the effective active state (sentinel, explicit id, or implicit
  first profile) before deciding, so makeActive:false never silently
  switches the active provider. Adds a regression test for the
  implicit-first-profile case (fails on the old ternary).

* fix(provider): honor stale active id and clear hydrated GitHub token on Anthropic switch

Addresses two findings on the switch-back-to-Anthropic path (#1426).

P2 — stale active profile id (providerProfiles.ts):
addProviderProfile's makeActive:false guard only preserved the
implicit-first-profile case when activeProviderProfileId was unset. If the
config carried an id for a deleted/missing profile, the guard treated it as "no
active" and promoted the newly added profile, ignoring makeActive:false — even
though getActiveProviderProfile() resolves a stale id to profiles[0]. Resolve an
effectiveActiveId the same way getActiveProviderProfile does (sentinel ->
built-in Anthropic, valid id -> that profile, stale/unset id with profiles ->
implicit first, none -> nothing active) and keep it when makeActive:false.
+regression test for the stale-id case (fails on the old guard).

P2 — hydrated GitHub token leak (ProviderManager.tsx):
Selecting "Use Anthropic (built-in)" while GitHub Models was active called only
clearActiveProviderProfile(), which clears managed flags but leaves a
GITHUB_TOKEN hydrated from secure storage (and its marker) in the session.
Mirror the GitHub delete path: new clearHydratedGithubModelsTokenFromEnv() drops
the hydrated token + marker while preserving a user-supplied token (one that
does not match the stored credential). +unit tests for match / user-supplied /
empty-storage / no-marker cases.

* fix(provider): keep switch-back reachable when a non-Anthropic provider is active

hasSelectableProviders gated the 'Set active provider' menu item, so when a
non-Anthropic provider (GitHub Models or a saved profile) was active but no
profile was saved and GitHub credentials were unavailable, the 'Use Anthropic
(built-in)' recovery option was unreachable. Add a scoped canSwitchActiveProvider
(true whenever GitHub is active or a profile is active) for the activate path
only; edit/delete still require an actual profile.

Add a ProviderManager UI test for the switch-back flow: select 'Use Anthropic
(built-in)' and assert the onDone state (provider name, model reset) and that no
managed CLAUDE_CODE_USE_* flags remain.

* fix(provider): drop dead wizard switch-back path, dedup switch guard

Address review on the legacy ProviderWizard 'anthropic' branch. ProviderWizard
is test-only (live /provider renders ProviderManager), so its switch-back option
duplicated the real path while diverging from it (no hydrated-GitHub-token
cleanup, no model reset) and went untested. Remove the wizard's 'Use Anthropic'
option, its handler branch, the now-unused ProviderChoice member, and the imports
only it used, leaving the single tested switch-back in ProviderManager.

Also reuse the component-scope canSwitchActiveProvider in renderMenu instead of
recomputing it, so the two sites cannot drift.

* test(provider): restore env + dispose mount in finally, assert token cleanup

Address review on the switch-back manager-UI test: snapshot and restore the
mutated process env and dispose the Ink mount in a finally block so a failed
wait or assertion cannot leak provider flags or a live mount into later tests,
and assert clearHydratedGithubModelsTokenFromEnv was called so dropping the
hydrated GitHub token cleanup cannot pass unnoticed.

* test(provider): assert switch-back forwards stored GitHub Models token

The switch-back test seeded no stored token (readGithubModelsToken
returned undefined) and only asserted clearHydratedGithubModelsTokenFromEnv
was called, so it would still pass if the branch stopped forwarding the
stored token into the helper. Seed a stored token and assert
toHaveBeenCalledWith(storedToken) so the regression covers the exact
GitHub Models switch-back path that preserves a user-supplied
GITHUB_TOKEN while clearing only the hydrated secure-storage token.

* fix(provider): keep built-in Anthropic active through the startup fallback

applyActiveProviderProfileFromConfig() returned without marking provider
env as handled for the Anthropic sentinel (getActiveProviderProfile resolves
it to undefined). On a cold start after clearActiveProviderProfile() deleted
the profile mirror, buildStartupEnvFromProfile() then treated the missing
file as a fresh install and synthesized the default Gitlawb OpenGateway env,
moving the user off built-in Anthropic. Clear managed provider env and set
the applied flag for the sentinel so the fresh-install fallback is suppressed;
an explicit startup provider selection still wins. Adds a cold-start
regression test.

* test(provider): assert startup-override cleanup and isolate cold-start env

Address review findings:
- ProviderManager switch-back test now anchors on the mocked
  clearStartupProviderOverrides symbol and asserts the Anthropic branch calls
  it, so the test fails if that call is dropped and a restart replays the
  third-party provider (proven fail-on-removal).
- Cold-start sentinel test snapshots and clears every CLAUDE_CODE_USE_* flag
  (OpenAI/GitHub/Gemini/Mistral/Bedrock/Vertex/Foundry) plus the base-url/model
  and applied markers, restoring them in finally, so an inherited provider flag
  can no longer route it down the explicit-selection path or leak into later
  tests.

* fix(provider): undo Copilot-key hydration on env cleanup

hydrateGithubModelsTokenFromSecureStorage() has two hydration modes: a
copilot_key blob populates GITHUB_COPILOT_KEY, while an OAuth blob
populates GITHUB_TOKEN. clearHydratedGithubModelsTokenFromEnv() only
cleared GITHUB_TOKEN, so undoing a copilot_key hydration removed the
ownership marker while leaving the hydrated Copilot key in the session.
Clear the GITHUB_COPILOT_KEY branch symmetrically (same stored-token
match guard that preserves a user-supplied value).

Adds helper coverage for both Copilot-key cases (matched key cleared;
user-supplied differing key preserved).

* fix(provider): revert hydrated Copilot key on GitHub provider delete

The GitHub Models delete path hand-rolled its own env cleanup that only
dropped GITHUB_TOKEN, so a hydrated copilot_key (stored in GITHUB_COPILOT_KEY
under the same marker) was left behind once the marker was removed. Delegate
to the shared clearHydratedGithubModelsTokenFromEnv helper so the delete flow
reverts both hydration modes consistently with the switch-back path, and add
a ProviderManager delete-flow regression test.

* test(provider): assert switch-back refreshes session AppState model

Capture AppState updates via onChangeAppState in the switch-back test and
assert the Use Anthropic (built-in) path sets mainLoopModel to the Anthropic
model from the result and clears mainLoopModelForSession to null. Without
this the test would still pass if the setAppState block regressed, leaving a
running session on the previous provider model.
2026-07-07 22:14:46 +08:00