Commit Graph
1149 Commits
Author SHA1 Message Date
jatmn 74ba4806cf test(openai-shim): assert Gemini tool-use stream blocks in adapter test
Extend the responseAdapters geminiSseToAnthropic wrapper test to cover
tool_use content_block_start, input_json_delta, and content_block_stop.
Remove stale post-extraction imports from the openaiShim facade.
2026-08-06 19:48:20 -07:00
jatmn bab9597b72 test(openai-shim): cover response adapter stream wrappers
Add focused regression tests for geminiSseToAnthropic and
openaiStreamToAnthropic through the responseAdapters facade wiring.

Validated with: bun test src/services/api/openaiShim/responseAdapters.test.ts
2026-08-06 19:19:20 -07:00
jatmn eac082d01b refactor(openai-shim): extract response adapters 2026-08-06 19:12:07 -07:00
0xfandomandGitHub c327805e1d fix(cost): guard model-cost lookup against prototype-member model ids (#2064)
* fix(cost): guard model-cost lookup against prototype-member ids

MODEL_COSTS is a plain object, so `MODEL_COSTS[shortName]` inherits
Object.prototype members. A model id of `constructor` or `__proto__`
-- both valid arbitrary ids for custom/OpenAI-compatible providers, and
already lowercase so getCanonicalName returns them unchanged -- resolved
to a truthy prototype value (the Object constructor / Object.prototype),
so the `!costs` unknown-model guard was skipped: trackUnknownModelCost
never fired and tokensToUSDCost read undefined rate fields, producing a
NaN cost that permanently poisons the running session total (total + NaN
stays NaN) and surfaces as "$NaN". getModelPricingString had the same
defect and rendered "$NaN/$NaN per Mtok".

Match on own properties via Object.hasOwn, mirroring resolveOutputStyle
in constants/outputStyles.ts. Add a regression test asserting proto-name
ids take the unknown-model path and yield a finite, positive cost.

* fix(cost): guard per-model usage tracking against prototype-member ids

getModelCosts was hardened, but the sibling per-model accounting kept the
same latent hole. STATE.modelUsage is a plain object, so a model id of
`constructor` / `__proto__` (arbitrary for custom/OpenAI-compatible
providers) reaches both getUsageForModel's read and the
`STATE.modelUsage[model] = ...` write. On the read, an absent proto-name
key resolves to an inherited Object.prototype member; addToTotalModelUsage
then does `modelUsage.inputTokens += ...` on that inherited object, and for
`__proto__` that lands on Object.prototype itself -- process-wide pollution
(every object gains inputTokens = NaN, etc.). On the write, bracket-setting
`__proto__` invokes the prototype setter.

Back modelUsage with a null-prototype map (emptyModelUsage) at init, reset,
and restore, so both operations act on ordinary own keys, and read through
Object.hasOwn to match the getModelCosts guard. Restore re-keys a persisted
breakdown (which can carry an own `__proto__` from JSON) into the null-proto
map. Regression test asserts no Object.prototype pollution and correct own-key
round-trip for proto-name ids.

* test(cost): tighten the proto-name model-cost regression

Assert Number.isFinite(cost) (rejects Infinity too, not just NaN) and that
the unknown-model detection flag fires, so a regression that dropped
trackUnknownModelCost while keeping the fallback tier would fail. Reset the
process-wide cost state afterward to avoid leaking into other suites, and
correct the comment: getModelPricingString has no production callers and
pre-fix threw a TypeError rather than rendering "$NaN/$NaN per Mtok".

* fix(cost): guard the /cost per-model aggregation against prototype-member ids

formatModelUsage accumulates per-model usage into a plain object keyed by
canonical short name. An unrecognised custom-provider id that canonicalizes to
`__proto__` / `constructor` (unchanged, since it matches no Claude pattern) made
the `!usageByShortName[shortName]` check read an inherited Object.prototype
member, skip initialization, and increment it in place -- for `__proto__` that
mutation lands on Object.prototype process-wide, and the model is dropped from
the displayed /cost breakdown. Use a null-prototype accumulator and an
Object.hasOwn guard, matching the getModelCosts / getUsageForModel fixes.

Regression drives the real addToTotalSessionCost -> formatTotalCost path for
`__proto__` and `constructor` ids, asserting no prototype pollution and that
both appear in the breakdown.

* test(cost): tidy the /cost proto-pollution regression harness

Load addToTotalSessionCost via a lazy ESM import instead of require (drops the
eslint suppression) and snapshot the guarded Object.prototype descriptors so
cleanup restores pre-existing state instead of unconditionally deleting keys a
sibling module might legitimately own.
2026-08-07 09:58:21 +08:00
BogdanandGitHub d834904e5a fix(session): make transcript replacements crash-safe (#2094)
* fix(session): make transcript replacements crash-safe

Complete transcript rewrites could truncate live JSONL files before preserved data was durable, risking unrecoverable resume history after an interrupted write. Commit replacements through exclusive sibling temp files and serialize them with all transcript append paths so readers observe either the old file or the complete replacement.

* fix(session): preserve concurrent transcript updates

Abort tombstone commits when the scanned transcript changes before replacement, and keep existing local history when remote foreground hydration returns no entries. Harden the associated portability, option coverage, queue timing, and diagnostics.

* test(session): match hydration reader signature

Pass the explicit optional subagent reader in the empty-hydration regression so a fresh TypeScript build sees the complete helper signature.

* fix(session): coordinate transcript writers across processes

Hold a same-directory cooperative lock across transcript replacement and final-line truncation, and make session plus SDK append paths participate. Exercise the post-validation/pre-rename race deterministically so external appends land after the complete commit.

* test(session): provide empty hydration subagent reader

* fix(session): scope transcript lock ownership

Separate async and synchronous lock ownership so unrelated sync appends cannot bypass an in-flight replacement. Route aliased in-process appends through the queue, propagate lock compromise through AbortSignal, and cover both symlink-alias and rename-boundary races.
2026-08-07 09:57:32 +08:00
BogdanandGitHub 6465a516f2 fix(mcp): serialize OAuth and XAA refresh across processes (#2093)
* fix(mcp): serialize OAuth and XAA refresh across processes

Normal OAuth refresh, reactive 401 recovery, and silent XAA exchange can otherwise race shared secure-storage writes between processes. Coordinate them on one server-scoped lock and re-read storage so waiters reuse persisted winners.

* fix(mcp): harden refresh follow-up paths

Use asynchronous cache-bypass reads on request paths while preserving the adjacent final record merge and write. Make the XAA concurrency fixtures independent of module import order and extend abort, redaction, and retry coverage.

* fix(mcp): honor aborts after credential reads

Check the active cancellation signal after asynchronous secure-storage reads so fresh-token fast paths cannot return credentials to an aborted request. Cover cancellation while a cache-bypassing read is pending.
2026-08-07 09:56:12 +08:00
BogdanandGitHub d427a4b2bb perf(cli): enable Node module compile cache (#2092)
* perf(cli): enable Node module compile cache

Warm CLI invocations spend substantial time compiling the bundled ESM entrypoint. Enable Node's optional on-disk compile cache only in the process that imports the bundle, while preserving early Node 22 compatibility and making cache failures non-fatal.

Add deterministic launcher coverage, packaging checks, and a reproducible benchmark procedure so the startup benefit can be measured without flaky CI thresholds.

* fix(ci): isolate minimum Node launcher check

The full validation suite depends on knip and oxc-parser behavior unavailable in Node 22.0.0. Keep full CI on the active Node 22 line and exercise the declared runtime floor in a dedicated build-and-launch job.

* fix(benchmark): harden startup measurements

Keep environment setup outside the timed process window, document the API's Node 22.8 floor, and preserve completed benchmark results when git metadata is unavailable.

* test(cli): verify compile cache disable behavior

Pair NODE_DISABLE_COMPILE_CACHE with a temporary cache directory and assert that supported Node releases leave it empty while preserving normal launcher output.
2026-08-07 09:55:01 +08:00
JATMNandGitHub bac012f70b refactor(openai-shim): extract transport lifecycle (#2071)
* refactor(openai-shim): extract transport lifecycle

* refactor(openai-shim): rebase transport extraction and address review

Rebase onto current main and clean up the transport extraction follow-ups:
drop stale facade imports left after the move and restore the full API
timeout parser negative-case coverage in transport.test.ts.

* test(openai-shim): address CodeRabbit review findings

Use path.join in the architecture guard, require transport.ts in the
mandatory extraction slice, strengthen Gemini stream conversion coverage,
and add transport deadline/cancellation regression tests with fake timers.

* test(openai-shim): assert manual signal cleanup after body cancel

Exercise the combineRequestSignals fallback without AbortSignal.any so
early body cancellation removes caller listeners and a later caller.abort
does not abort the combined fetch signal.

* test(openai-shim): restore AbortSignal.any when initially absent

Delete the temporary AbortSignal.any override when the runtime did not
define an own property, so transport and facade signal-cleanup tests leave
global AbortSignal state unchanged for later cases.
2026-08-07 09:51:48 +08:00
BogdanandGitHub 1bf8076d48 fix(input): preserve text in DEL-coalesced chunks (#2091)
* fix(input): preserve text in DEL-coalesced chunks

Some terminal transports deliver replacement input as raw DEL bytes and printable text in one read. The raw-DEL workaround previously applied only the deletions and returned, dropping the replacement text and leaving same-event cursor and mode state stale.

Process filtered chunks in source order through the existing cursor semantics, preserve coalesced submission and Vim state, and cover grapheme, token, filter, mode, and batching cases.

* test(input): harden DEL regression coverage

* test(input): clean up harnesses after timeouts

* fix(input): preserve coalesced consumer state

* fix(input): synchronize coalesced mode state
2026-08-07 09:49:01 +08:00
மனோஜ்குமார் பழனிச்சாமிandGitHub 95eeb0bde3 feat(cli): add --yolo alias for --dangerously-skip-permissions (#2097)
Register the alias on the main command and the ssh stub. Recognize it in
the cc:// and ssh raw-argv scans, and in both skills pre-parse boolean sets
(leading and trailing), so  and
 route correctly. Update the web flags docs.

Includes source-scan + help-text tests proving the alias is wired through.
The SSH/argv refactor remains on the existing feat/yolo-flag branch for a
separate follow-up PR.
2026-08-07 09:47:47 +08:00
JATMNandGitHub b0cbfe1100 fix(repl): make local interactive max-turns configurable (#2086)
* fix(repl): make interactive max-turns configurable

Wire --max-turns into interactive sessionConfig and honor
OPENCLAUDE_MAX_TURNS / CLAUDE_CODE_MAX_TURNS so long autonomous REPL
sessions can raise the default 50-turn per-prompt cap (fixes #2079).

* fix(repl): forward --max-turns on connect/ssh/remote launches

sessionConfig covered the normal interactive paths; connect, SSH,
assistant, and --remote built REPL props without spreading it, so the
CLI override was dropped despite help advertising interactive support.

* fix(repl): scope interactive max-turns to local query loops

Remote-backed sessions bypass local query(), so forwarding --max-turns
into those REPL props over-claimed enforcement. Clarify help/docs and
match OPENCLAUDE_MAX_RETRIES precedence when OPENCLAUDE_MAX_TURNS is set
but invalid.

* feat(config): add interactive max turns under /config

Expose replMaxTurns in the Config panel (50/100/200/500) and resolve it
after CLI/env so local interactive sessions can raise the per-prompt
cap without restarting. Resolve at query time so mid-session /config
changes apply on the next prompt.

* docs(repl): clarify invalid OPENCLAUDE_MAX_TURNS precedence

Match the OPENCLAUDE_MAX_RETRIES contract: a set-but-invalid primary
env var uses the default and does not fall through to legacy or /config.

* fix(repl): address PR review on max-turns help and web version gate

Share the --max-turns Commander description via an imported constant so
help and tests stay in sync without breaking the CLI bundle, replace
source-only help assertions with Commander behavior coverage, and add
the published 0.27.0 entry so web verify-dist passes.

* fix(repl): typecheck Commander maxTurns opts and warn on invalid env

Avoid TS2339 on untyped Commander opts, log invalid OPENCLAUDE_MAX_TURNS
like MAX_RETRIES, and clarify that /config shows the persisted preference.

* fix(repl): warn when max turns is unlimited

* fix(repl): scope unlimited-turn warning locally

* fix(repl): preserve interactive turn caps across backgrounding

* fix(repl): preserve turn caps when backgrounding

* fix(repl): share turn budget across background handoff

* fix(repl): reserve turns at provider dispatch

* fix(repl): snapshot background handoff transcript

* fix(tasks): avoid phantom background session task

* fix(repl): preserve handoff lifecycle state

* fix(repl): own pending background handoffs

* test(tasks): isolate background session task storage

* fix(repl): refresh background task title and test cleanup

* test(repl): cover max-turn CLI dispatch paths

* test(queue): cover prepend notification and priority

* fix(repl): skip background handoff after foreground query throws

Rebased onto main and gate Ctrl+B continuation on !didThrow so a faulted
foreground turn cannot start a background session from partial state.

* fix(repl): address PR review findings on notifications and handoff

Dedupe background task notifications by embedded task id, gate background
continuation on preflight veto, scope queue removal to main-thread notifications,
and forward maxTurns through all launchRepl entry points.

* fix(repl): resolve latest CodeRabbit inline review findings

Dedupe claimed notification batches by task id, restore notifications on
pre-registration abort, tighten test isolation, and replace remaining brittle
source-text assertions with behavioral coverage.

* fix(repl): keep notification restore active until provider dispatch

Stop clearing notification ownership when preparation succeeds so pre-dispatch
aborts can restore claimed queue items, and commit ownership once the provider
starts. Add regression coverage for the abort path and headless max-turns zero.

* test(repl): cover post-dispatch ownership and headless max-turns 0

Add regression tests for notification restore after provider dispatch commits
ownership, and assert headless --max-turns 0 reaches query() without interactive
resolution stripping the value.

* fix(repl): restore only embeddable notifications on Ctrl+B handoff abort

Track the deduped successor subset when restoring claimed main-thread task
notifications so items already in the settled foreground transcript are not
re-queued. Clarify that agent-scoped notifications intentionally stay on their
owner drain path (issue #2079 scope is interactive turn caps only).

* fix(repl): address review findings on background handoff

Commit notification ownership when background sessions complete without
provider dispatch, forward all task notifications on Ctrl+B again, and
restore deferred max-turn cap attachments when continuation is cancelled.

* fix(repl): guard deferred cap restore and skip remote turn limits

Anchor deferred max-turn restoration to the handed-off transcript tail so a
cancelled Ctrl+B handoff cannot attach the prior prompt's cap to a newer turn.
Apply the interactive turn cap only in local sessions and align remote-session
docs/help wording.

* fix(repl): use messagesRef for deferred cap transcript anchor

persistentMessages is block-scoped inside onQuery try; read the settled
tail from messagesRef in finally so typecheck passes.

* test: harden context fallback warning assertion after max-turns tests

Scope the unknown-model context test to [context] warnings only so unrelated
import-time debug logs do not fail CI, and clear turn env vars in both that
test and replMaxTurnsProp setup to avoid cross-file pollution.

* test: address PR review findings on headless max-turns boundary

Add a runHeadless-to-ask regression that asserts maxTurns 0 is forwarded
through the headless print path, and restore OPENCLAUDE_MAX_TURNS env vars
in context.test.ts after the unknown-model fallback test mutates them.

* test: tidy headless max-turns boundary test and env isolation

Mock headless stdout so runHeadless completes cleanly without leaking
output, restore spies in finally, and centralize turn-env cleanup in
context.test beforeEach.

* fix: address PR review findings for max-turns background handoff

Separate model-request lifecycle from provider dispatch acceptance so
interruption correction arms before async prep, notification ownership
commits only after dispatch, deferred turn caps restore on every abort
path, and foreground work stays blocked while handoff preparation runs.
2026-08-06 20:24:34 +08:00
0xfandomandGitHub 2c42a325d9 fix(permissions): anchor the session plan-file match on its exact shape (#1994)
* fix(permissions): anchor the session plan-file match on its exact shape

isSessionPlanFile auto-allows the current session's plan file for both
read (checkReadableInternalPath) and un-prompted write
(checkEditableInternalPath). It matched with a bare
normalizedPath.startsWith(join(plansDir, planSlug)), which also accepts
any sibling whose name merely begins with the slug — {slug}nova.md,
{slug}-other.md, or a newly-created {slug}dir/ subtree. Those are not this
session's plan yet were silently readable and writable without a prompt.

Anchor on the two shapes getPlanFilePath actually emits: {slug}.md exactly,
or a {slug}-agent- prefix for subagent plans. Extract the decision into a
pure isPlanFilePath(plansDir, slug, path) helper so it can be unit-tested
without session state. normalize() still runs first, so traversal segments
can't escape the plans directory.

Same missing-separator class as the path-containment fix in #1974.

* fix(permissions): restrict the agent-plan branch to a single filename

The -agent- prefix check still matched any path beneath a lookalike
sibling directory: {plansDir}/{slug}-agent-evil/anything.md passed
startsWith and ended in .md, so both permission carve-outs granted
unprompted read and write to arbitrary files below it. The malformed
{slug}-agent-.md, which getPlanFilePath never emits, was accepted too.

Require the remainder after the prefix to be exactly one nonempty agent id
followed by .md — no path separators.

* fix(plans): keep separator-carrying agent ids in one filename component

The anchored predicate rejected any agent id containing a path separator,
but producers can emit one: TeamCreateTool accepts any nonblank team name
and teammate spawning only strips `@` from the teammate name, so a team
called `a/b` yields the path {plansDir}/{slug}-agent-writer@a/b.md. That
is a file in a subdirectory, not a plan file, so the teammate lost the
carve-out for its own plan and was blocked in plan mode.

Escape the separators where the path is built instead. Percent-escaping is
reversible, so two teammates can never collide on one plan file, and ids
without those characters are untouched -- existing plan files keep their
paths.

* fix(plans): recover plans written under the unescaped agent id

Escaping changes the pathname for teammates whose id already contains a
separator, and team names have always accepted arbitrary nonblank text --
so plans for ids like writer@a/b or writer@100% are already on disk under
the raw name. Every reader now builds the escaped name, so on upgrade the
teammate's plan reads as missing and a second file is created beside it.

getPlan falls back to the unescaped path on ENOENT and moves the file to
the escaped name. Moving rather than copying is what makes it stick: the
escaped name is the one the permission carve-out recognizes, so a plan left
at the old path would keep falling through to ordinary permission handling
on every later write. A failed move is not fatal, the content is already
read.

The recovery takes explicit paths so it is covered against a real temporary
directory rather than a mocked filesystem.

* fix(plans): confine legacy plan recovery to the plans directory

readLegacyUnescapedPlan builds the pre-escape path from the raw, unescaped
agent id so an existing file can be found. Team/agent names accept arbitrary
nonblank text, so a traversal-shaped id (`../../../etc/passwd`) collapses to
a path outside the plans directory -- which readAndMigrateLegacyPlan then
reads and renames, moving an arbitrary file. Refuse any resolved path the
plans directory does not contain before delegating.

* fix(plans): give escaped agent plans a collision-free namespace and harden recovery

The escaped filename shared a directory with legacy plans, so two distinct
teammates could map onto one file: `writer@a/b` writes the escaped
`{slug}-agent-writer@a%2Fb.md` while `writer@a%2Fb` already owns that exact
name as its raw legacy plan -- a cross-agent read and clobber. Store escaped
agent plans under a dedicated `agents/` subdirectory: a real path separator
is the one thing a raw single-component legacy name can never contain, so
the two namespaces are provably disjoint. The permission carve-out
(isPlanFilePath) recognizes the new location.

Harden legacy recovery, which reads then renames a file built from the raw
(unescaped) agent id:
- Reject any `..` segment before building the path, so `a/../{slug}` can no
  longer collapse onto the main plan (or `a/../{slug}-agent-victim` onto a
  sibling) and have recovery move another agent's file.
- Make migration no-clobber: never rename a legacy file over a plan already
  present at the escaped path.
- Export readLegacyUnescapedPlan (with injectable plansDir/slug) so the guard
  is covered through the recovery flow, not just isPathWithinPlansDir alone.

* test(plans): cover getPlan's ENOENT recovery wiring end to end

The recovery helpers are unit-tested, but nothing drove getPlan() itself
through the ENOENT fallback -- the whole user-visible fix. Add a test that
plants a legacy plan under a temp config dir and asserts getPlan() returns
its contents and migrates it into the agents/ subdirectory, serialized
under the shared mutation lock since it swaps OPENCLAUDE_CONFIG_DIR.

* fix(plans): anchor plan-file matching on the canonical encoding and harden recovery

Addresses review on the agent-plan permission carve-out.

isPlanFilePath accepted any `{slug}-agent-<x>.md` whose `<x>` had no raw
`/` or `\`, but getPlanFilePath emits only the canonical output of
encodeAgentIdForPlanFile (escapes `%`->`%25`, `/`->`%2F`, `\`->`%5C`). So a
raw-percent sibling such as `{slug}-agent-writer@100%.md` (canonical form
`...writer@100%25.md`) was auto-allowed for unprompted read/write even though
the producer never writes it. Add decodeAgentIdForPlanFile and
isCanonicalPlanFileEncoding (a component is canonical iff re-encoding its
decode reproduces it byte-for-byte) and anchor the agent branch on it. This
accepts every path the encoder can emit and rejects raw-`%`/raw-separator
lookalikes, subsuming the previous separator-only check.

Also harden legacy recovery, which reads and renames a path built from the
raw agent id:
- getPlan now treats an empty/whitespace escaped file as not-a-plan and falls
  through to legacy recovery. isPlanFilePath permits a direct FileWrite/FileEdit
  to the canonical escaped path before migration runs; such a stub would
  otherwise permanently shadow a legacy plan that still holds content. Recovery's
  no-clobber guard returns the legacy contents without renaming over the stub,
  so a genuine concurrent escaped write is never lost.
- readAndMigrateLegacyPlan lstat-checks the legacy slot and refuses anything
  that is not a regular file, so a symlink planted there cannot make recovery
  read and rename an arbitrary target outside the plans directory.

Tests: canonical-vs-lookalike pairs for `%`/separator ids, getPlan driven
end-to-end for a separator id and for the empty-stub fallthrough, and a
symlinked legacy slot. The getPlan integration tests acquire the shared
mutation lock inside try/finally and clear the plan slug on teardown.

* fix(plans): close symlink and race gaps in plan-file recovery and the carve-out

Second review pass on the agent-plan hardening.

- Symlinked path components no longer bypass the lexical carve-out. The plan-file
  permission grant (isSessionPlanFile) now resolves the deepest existing ancestor
  of the target and requires it to stay within the *resolved* plans directory, so
  a symlinked `agents` subdir (or plans dir) that redirects the real file outside
  the plans directory is refused instead of auto-allowed. Legacy recovery gets the
  same containment check, closing the slash-bearing-id case where a symlinked
  intermediate `{slug}-agent-writer@a` parent passed the prefix checks and leaf
  lstat.
- Migration is now a genuine no-clobber move: linkSync (atomic, fails EEXIST)
  replaces the existsSync-then-renameSync check-then-act race that could replace a
  concurrently-created live plan on POSIX. The escaped hard link pins the inode we
  lstat'd, and we read through it, so a symlink swap of the legacy pathname cannot
  redirect the read. Reads verify the inode/device are unchanged across the read.
- Traversal validation uses the host platform's real separators: on POSIX `\` is a
  legal filename character, so a legacy id like `a\..\b` (persisted as one flat
  filename) recovers again instead of being wrongly rejected; Windows still treats
  both `/` and `\` as separators.

Tests: symlinked intermediate directory rejection (helper + recovery), POSIX
literal-backslash recovery, genuine-move semantics. All fail on the pre-fix code.

* refactor(permissions): reuse the shared plans-dir containment helper

Drop the duplicate isResolvedWithinPlansDir in the permission layer and route
the session plan-file carve-out through the exported isResolvedPathWithinPlansDir
from plans.ts, keeping the symlink-containment logic in one place. Guard the two
symlink-based tests on non-Windows so they skip where symlinkSync needs
privileges.
2026-08-06 20:23:21 +08:00
keyarvsfandGitHub 248424ffe3 fix(model-picker): eliminate O(n²) catalog rebuild lag in /model (#2078)
* fix(model-picker): eliminate O(n²) catalog rebuild lag in /model

getModelOptions() ran an O(n²) optionMatchesModel loop (catalog scan per
option) plus an O(n²) duplicate-apiName filter, costing ~43ms per call on
catalogs with hundreds of models (e.g. Fireworks' ~280 entries). The
picker also rebuilt the full options list on every keystroke via
isGenuineSwitchProfileValue, so arrow-key navigation lagged badly.

- hoist catalog lookup out of the per-option loop (hasOptionValue)
- precompute duplicate apiNames into a Set
- short-circuit isGenuineSwitchProfileValue for non-switch values

getModelOptions(): 43.5ms -> 2.0ms on a 277-entry catalog

* perf(model-options): share route-catalog context across getModelOptions checks

getRouteCatalogModelOption re-resolved the active route, fetched the
catalog entries, and rebuilt the duplicate-apiName set on every call —
getModelOptions() invoked it up to 3x per build (env custom model, each
scoped additional option, active custom model + fallback), so large
catalogs (Fireworks ~280 entries) paid O(n) context rebuilds repeatedly.

Build the RouteCatalogContext lazily once per options build and pass it
through findRouteCatalogOption/hasOptionValue. Behavior unchanged; the
catalog-miss path measures 6.1ms -> 4.1ms on a 277-entry catalog.

* test(model-picker): add regression tests for catalog dedup and switch-profile guard

* test(model-picker): gate process-wide mocks in regression tests

* test(model-picker): reuse mocked modelOptions instance in switch-profile test

* test(model-picker): prevent mock leakage

* test(model-picker): drop dead env overrides overwritten by catalog dedup helper

OPENAI_BASE_URL and OPENAI_MODEL assigned in the scoped-cache test were
immediately overwritten by getRouteCatalogModelOptions, so they never
affected the exercised path. Remove the dead assignments; keep
OPENAI_API_KEY to preserve the helper's auth path.

* test(model-picker): assert getModelOptions skipped for ordinary ids

isGenuineSwitchProfileValue short-circuits on the switch-profile prefix,
skipping the getModelOptions() rebuild for ordinary model ids. Track the
gated getModelOptions binding ModelPicker captures (opt-in call-through
mock in importFreshModelPicker) and assert it is never invoked for
non-prefixed ids, alongside the existing false-result assertions.

* test(model-picker): address review feedback on spies, fetch bounds, and alias dedup
2026-08-06 20:22:29 +08:00
5844d1fe8a Feat/ultracode blue spinner (#2096)
* feat(ultracode): add blue/cyan spinner and effort visual treatment

- Add EFFORT_ULTRACODE (◆) figure for effort display surfaces
- Add ultracode/ultracodeShimmer theme colors across all 6 theme variants
- Wire ultracode case into effortLevelToSymbol() for icon rendering
- Use blue-cyan RGB shimmer for "thinking" text when ultracode is active
- Set spinner color override in REPL when displayed effort is ultracode

* feat(ultracode): tint prompt border and unify shimmer to theme tokens

Add a persistent cyan-blue prompt border whenever ultracode is the active
effort, reacting immediately to /effort and ranking below bash/teammate
overrides. Derive the spinner thinking-shimmer from the ultracode/
ultracodeShimmer theme tokens (with an ANSI/daltonized fallback) instead of
a divergent hardcoded cyan, so border, spinner, and shimmer share one source
of truth.

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

* test(spinner): cover ultracode shimmer color selection and ANSI fallback

Extracts the thinking-shimmer color computation into an exported
getThinkingShimmerColor helper (renderToString strips ANSI color, so the
selection logic is only observable through a direct call) and adds focused
tests for ultracode rgb() token interpolation, the ansi:* fallback
endpoints, and the non-ultracode gray interpolation.

Addresses CodeRabbit review on #2096.

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

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-08-06 13:07:46 +08:00
JATMNandGitHub 63fda83d55 fix(web): add v0.27.0 changelog entry and clarify release-data ownership (#2075)
* fix(web): add 0.27 release entry

* test(web): cover 0.27 release entry

* fix(web): clarify 0.27 permission-timeout highlight

* docs: ban drive-by edits to web/src/data/releases.ts

Tell agents and contributors that the curated changelog is owned by
the release/web process, and stop verify-dist from instructing unrelated
PRs to patch it when npm publishes ahead of the site.

* test(web): locate curated release by version

* test(web): enforce newest-first release order
2026-08-03 10:54:52 +08:00
JATMNandGitHub b3735bedb3 refactor(openai-shim): extract request executor helpers (#2011)
* refactor(openai-shim): extract request execution

* fix(openai-shim): rebase executor extraction

* test(openai-shim): preserve local stream options coverage

* test(openai-shim): isolate Azure compatibility state

* fix(openai-shim): preserve executor retry contracts

* fix(openai-shim): retain route credential isolation

* fix(openai-shim): preserve executor transport behavior

* fix(openai-shim): avoid duplicate local retries

* fix(openai-shim): preserve LongCat credential routing

* fix(openai-shim): retain executor abort contracts

* fix(openai-shim): preserve fallback cancellation

* fix(openai-shim): preserve executor retry and transport contracts

* fix(openai-shim): stabilize extracted executor smoke coverage

* test(openai-shim): cover extracted executor retry contracts

* test(openai-shim): assert redacted HTTP errors

* test(openai-shim): isolate Azure executor configuration

* fix(openai-shim): preserve executor recovery retries

* test(openai-shim): remove migrated executor duplicates

* docs(openai-shim): clarify extracted façade budget

* test(openai-shim): enforce extraction modules

* fix(openai-shim): stop retrying cooled GitHub keys

* fix(openai-shim): avoid concurrent cooled-key retries

* test(openai-shim): stabilize pooled-key retry coverage

* fix(openai-shim): preserve newer credential cooldowns

* docs(openai-shim): explain stale auth eviction
2026-07-31 14:39:26 +08:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
7eeb90fb5b chore(main): release 0.27.0 (#2055)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.27.0
2026-07-31 08:48:38 +08:00
5cac15cbda fix(minimax): mark MiniMax-M2.7 as text-only input (#2068)
Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
2026-07-30 21:52:02 +08:00
77c82829c4 docs(readme): add npm monthly downloads badge (#2069)
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-30 15:57:28 +08:00
JATMNandGitHub 8df37c78f4 fix(agents): allow subagents from multi-repo parent sessions (#2063)
* fix(agents): allow subagents from multi-repo parent sessions

Expose Agent cwd in the open build, let cwd select the child repo for
worktree isolation, and fall back instead of hard-failing when the
session itself is outside a git repository.

* fix(agents): persist cwd on resume and forward it to worktree hooks

Address final-head review: store explicit Agent cwd in metadata for
resume, pass cwd into WorktreeCreate hooks, reject relative cwd in the
schema, and make the multi-repo parent regression sandbox portable.

* fix(agents): keep child-repo cwd across worktree cleanup and resume

Persist explicit Agent cwd even when a worktree is created, preserve it
when unchanged worktrees are removed, and base fork worktree notices on
the child-repo cwd for multi-repo parent sessions.

* fix(agents): re-persist child-repo cwd on every resume

Always forward persisted Agent cwd through resume metadata writes so a
mid-life resume cannot drop the multi-repo fallback path, and tighten
prompt wording to match the missing-git fallback contract.

* fix(agents): validate cwd directories and recover from worktree hook failures

Require Agent cwd to be an existing directory, always re-persist the
original resume metadata cwd, and fall through from failed WorktreeCreate
hooks to git when the selected cwd is a git repository.

* fix(agents): keep WorktreeCreate hooks authoritative

Revert silent git fallback after hook failure. Treat WorktreeCreate hook
errors as recoverable in AgentTool so multi-repo cwd overrides still work
without bypassing configured hooks at the worktree layer.

* fix(agents): only soft-fallback missing-git worktree errors

Keep WorktreeCreate hook failures hard-failing so configured hooks stay
authoritative in normal git sessions. Soft-fallback remains limited to
the missing-git path that #2052 needs.

* docs(agents): clarify missing-git cwd fallback wording

Align AgentTool prompt and resume debug logs with the missing-git-only
soft-fallback contract for multi-repo parent sessions.

* fix(agents): keep fork worktree notices on session cwd

Inherited fork context paths are relative to the parent session, so the
worktree notice must use getCwd() even when isolation used a child-repo cwd.

* docs(agents): align runAgent cwd JSDoc with resume persistence

* fix(agents): address CodeRabbit cwd validation review notes

Use afterAll for schema-test temp cleanup, and preserve the underlying
stat failure reason when Agent cwd validation rejects a path.

* fix(agents): surface worktree isolation fallback visibly

Make the missing-cwd schema test path platform-neutral, and record a
user/model-visible notice plus tool-result flag when worktree isolation
soft-falls back outside a git repository.

* fix(agents): surface worktree fallback when sync agents background

Share async_launched payload construction so the sync-to-background path
includes worktreeIsolationFallback when worktree isolation soft-falls back.
2026-07-30 11:56:59 +08:00
JATMNandGitHub 871bf28568 refactor(openai-shim): extract typed request body planning (#2010)
* refactor(openai-shim): extract request planning

* test(openai-shim): cover Bankr route credential base

* test(openai-shim): retain empty Responses fallback coverage

* fix(openai-shim): address planner review findings

* test(openai-shim): cover serializeBody transport routing

Add planner tests that exercise serializeBody() for responses,
anthropic_messages, and gemini transports, including omit-flag rebuilds.
Remove the vacuous Gemini tool_choice assertion that never guarded behavior.
2026-07-30 11:55:24 +08:00
e636f7d1cb feat(opengateway): add Macaron V1 Tall to the gateway catalog (#2067)
* feat(opengateway): add Macaron V1 Tall to the gateway catalog

Served by opengateway via direct Novita (model is not on OpenRouter).
Free launch window; the gateway delists it 2026-08-10. Adds the model
and brand descriptors and regenerates integration artifacts.

* test(opengateway): add Macaron regression coverage + picker expectation

Adds macaron.test.ts (descriptor capabilities/limits, gateway catalog
apiName/modelDescriptorId wiring, runtime limits — tencent.test.ts
pattern) and includes mindai/macaron-v1-tall in the /model picker's
expected opengateway option list.

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-30 11:54:38 +08:00
JATMNandGitHub ae634cdef3 refactor(openai-shim): extract stream lifecycle and response dispatch (#2009)
* refactor(openai-shim): extract client dispatch

* fix(openai-shim): preserve max reasoning effort dispatch

* test(openai-shim): cover injected Codex dispatch
2026-07-29 20:47:28 +08:00
56a920196d feat(web): replace favicon/logo with Ember Block O brand mark (#2065)
The site icon was still the 2026-06 terminal-face + git-fork circuit
mark, predating the ember identity the product now leads with (the
ANSI-Shadow startup logo and the orange pixel wordmark in the README).

Replace it with the Ember Block O: the startup screen's figlet "O"
letterform re-plotted as pure SVG rects — five ember gradient bands
(#ffb15f → #be5008, the exact stops from StartupScreen.palettes.ts)
with the wordmark's thin offset outline shadow, on a dark rounded tile.
Reads as a crisp orange O at 16px and matches CLI, README, and site.

- openclaude-logo.svg: new mark (same filename, Head.astro untouched)
- openclaude.png: 512px transparent-corner render (PNG favicon and the
  nav/footer images, which already reference this path)
- og/{default,docs,commands,buddy}.png: all four social cards
  regenerated with the new mark; layout, copy, and grid unchanged

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-29 20:45:24 +08:00
c2030bbb2b fix(web): make web/ build standalone — stop importing the repo-root p… (#2061)
* fix(web): make web/ build standalone — stop importing the repo-root package.json

vercel --prod deploys only the web/ directory, so site.ts importing
../../../package.json (and verify-dist.ts reading it) broke every Vercel
build with ts(2307) while local builds passed.

- SITE.version now derives from latestVersion, the newest entry in
  src/data/releases.ts — committed data inside web/, so builds are
  deterministic and need nothing outside the directory
- verify-dist gains a best-effort npm freshness guard: fails the build
  only when registry.npmjs.org reports a newer @gitlawb/openclaude than
  releases.ts; unreachable registry or malformed responses skip the
  check, and site-ahead-of-npm is allowed for release PRs
- verify-dist.test.ts covers the guard via injected fetch (no network)

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

* fix(web): reject leading-zero semver from the npm registry

Number() would normalize a malformed '01.2.3' to 1.2.3; require strict
semver components so malformed registry values skip the freshness check
instead of being silently coerced.

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

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-29 13:12:35 +08:00
0f76b5490b feat(web): v0.26 refresh — buddy page, changelog, partners, provider … (#2060)
* feat(web): v0.26 refresh — buddy page, changelog, partners, provider catalog

- Single-source the site version from the root package.json (never stale again)
- New /buddy/ page: all 7 hero sprites rendered as animated SVGs generated
  from src/buddy/pixelSprites.ts, attack descriptions, commands, hatch lore,
  plus a dedicated 1200x630 OG image composed from the real sprites
- New /changelog/ page: curated release highlights 0.19 -> 0.26 from a typed
  releases.ts data file
- Landing: buddy teaser section, partners strip (GitLawb, Bankr, Atomic Chat,
  Xiaomi MiMo, Atlas Cloud, AI/ML API, Novita AI) with self-hosted logos,
  community links, refreshed provider strip, node >= 22 fix
- Providers docs rebuilt as grouped catalog (39 providers: subscriptions,
  gateways, vendors, local, custom) incl. xAI OAuth, AI/ML API, Cloudflare
  Workers AI, NVIDIA NIM, Kimi K3, GPT-5.6, Opengateway free models
- Data refresh vs v0.26.0 source: 16 new slash commands, pdf skill, new CLI
  flags + 10 subcommands, modelLimits/providerFallbackChain/agentRouting
  settings, corrected env vars (GEMINI_API_KEY, OPENGATEWAY_API_KEY, ...)
- Nav/footer/docs sidebar link the new pages; JSON-LD breadcrumbs on both

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

* fix(web): address CodeRabbit review — flag description + dist verification

- Correct --disable-slash-commands description: the flag empties the entire
  slash-command list (REPL.tsx filters all commands), not just skills; the
  upstream help string "Disable all skills" is the misleading one
- Add scripts/verify-dist.ts, wired into `bun run build` (so the existing CI
  web job runs it): asserts SITE.version matches the root package.json in the
  rendered pages, nav exposes /buddy/ and /changelog/, every release renders
  with its GitHub URL, every hero renders with its sprite asset, partner and
  community links render on the landing page, and the sitemap covers the new
  routes

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

* fix(web): harden verify-dist per review — empty-page guard, rendered-nav check, tests

- page() now records a failure for a present-but-empty file, so '' is only
  ever returned alongside a recorded failure and skipped assertions can no
  longer mask a blank page
- assert the rendered docs sidebar (dist/docs/) links every docsNav route,
  not just the source data array and the landing nav
- extract pure verifyDist(dist) and add 9 fixture-based bun tests covering
  missing/empty pages, lost sidebar links, missing sprites, stale partner
  links, missing release URLs, and sitemap regressions; discovered by the
  root `bun test` run in CI, no workflow changes needed

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

* test(web): derive the missing-sprite fixture from heroes data

Hard-coding robinhood.svg would make the test throw during fixture mutation
if that hero were renamed, instead of exercising verifyDist().

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

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-29 12:32:47 +08:00
10a9190bea fix(ui): keep SpinnerModeGlyph visible inside status parens (#2047)
* fix(ui): keep SpinnerModeGlyph visible inside status parens

Always render the ↑/↓ mode glyph for leader spins so early requesting
and thinking-only phases are not blank, and place it as the first
status part inside the parentheses next to other activity cues.

Closes #2033

* fix(ui): preserve narrow-terminal thinking with mode glyph

Restore a second-chance width gate for leader thinking-only status under
the new inside-parens glyph layout, and suppress the mode glyph when the
row cannot fit minimal status chrome.

* fix(ui): prefer bare thinking over glyph-only on narrow rows

When leader thinking-only cannot fit glyph+thinking chrome, fall back to
bare (thinking) instead of empty mode-glyph status. Tighten glyph residual
budget to account for GlimmerMessage trailing space.

* fix(ui): budget glimmer space in bare thinking fallback

Bare leader thinking-only residual must reserve the GlimmerMessage
trailing space so equality-width terminals do not overflow by one column.

* fix(ui): nest teammate bare thinking under reduced motion

Apply the bareThinkingOnly nested (thinking) wrap in both shimmer and
dimColor branches so teammate thinking-only status keeps parentheses
when reduced motion disables the shimmer arm.

* test(ui): assert exact SpinnerAnimationRow status rows

Fix TS1355 from invalid null as const in baseProps and replace partial
toContain/regex checks with full ANSI-stripped row equality for the
glyph placement regressions CodeRabbit requested.

* fix(ui): prefer status content over empty mode-glyph chrome

When reserving the SpinnerModeGlyph would drop tokens/timer from the
status row, drop the glyph instead. Keep thinking full-chrome recovery,
default unknown modes to down-arrow, and tighten exact-row tests for
typecheck plus CodeRabbit feedback.

* fix(ui): preserve spinner tokens when glyph crowds status

* fix(ui): suppress empty glyph chrome and preserve token recovery

Skip glyph-only status when numeric thinkingStatus cannot fit on narrow terminals, and refuse glyph-free recovery that would swap visible tokens for a timer-only layout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): tighten glyph recovery for duration and timer bands

Exclude numeric post-thinking duration from glyph-only status (including requesting), prefer streaming tokens over duration when both cannot fit, and recover timer+token rows at the col-30 boundary.

* fix(ui): harden SpinnerAnimationRow glyph recovery priorities

Prefer tokens over timer/duration on mid-narrow rows, recover exact-fit
token columns, keep full effort text when glyph chrome fits, and prefer
active thinking over timer-only status. Tests now use production
Thinking… message width and frozen-clock exact row assertions.

* fix(ui): address CodeRabbit SpinnerAnimationRow recovery nits

Document token-over-thinking tie-break and > vs >= bare-pass split, drop
redundant suppressModeGlyph assignments on token-only fallbacks, and make
exact-row tests use PROD_MESSAGE plus an explicit padded mode-glyph helper.

* fix(ui): drop overflowing spinner suffix for tokens/thinking

Recover mid-narrow status when stop-hook/tool suffixes overflow bare
chrome, prefer live tokens over duration after the drop, and cover long
production verb column bands plus suffix regressions.

* fix(ui): recover status after SpinnerAnimationRow suffix overflow

Re-gate tokens when thinkingStatus is null after dropping a crowding
suffix, restore teammate nested thinking on the same path, and drop the
mode glyph before truncating a suffix that still fits under bare parens.

* fix(ui): complete SpinnerAnimationRow suffix and glyph recovery

Restore timer symmetrically after suffix drop, drop crowding suffixes
when preferTokens would overflow, keep already-visible thinking when
tokens unlock, and prefer tokens over a bare-fitting suffix that cannot
share the row.

* fix(ui): harden SpinnerAnimationRow recovery against wrap cliffs

Budget timer co-restore against all visible parts, re-gate tokens onto
timer-only rows after tokens-over-suffix, prefer thinking over a crowding
bare-fit suffix, and restore the mode glyph only after a suffix-keep
cascade.

* fix(ui): close SpinnerAnimationRow mid-narrow recovery cliffs

Prefer tokens over thinking when they cannot share bare chrome, tighten
thinking-over-suffix exact-fit to avoid a one-column suffix cliff, and
restore the mode glyph whenever recovered leader content fits.

* test(ui): cover SpinnerAnimationRow cliff and glyph-restore cases

* fix(ui): restore tokens beside thinking after suffix recovery

* fix(ui): close SpinnerAnimationRow suffix recovery cliffs

Budget timer and rendered thinking width in suffix-fit predicates so
widening does not drop the elapsed timer or streaming tokens. Co-restore
teammate tokens when thinking crowds timer-only rows, clear the mode glyph
when thinking+token recovery cannot fit glyph chrome, and budget full
effort text before keeping a stop-hook suffix.

* refactor(ui): collapse redundant SpinnerAnimationRow suffix-fit branches

Rely on the combined all-visible suffix budget check instead of
duplicate tokens-only paths. Keeps timer+thinking and no-token
thinking fallbacks unchanged.

* fix(ui): re-gate recovered spinner glyph

* test(ui): tighten spinner layout coverage

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:40:32 +08:00
JATMNandGitHub 2fe1e1b148 refactor(openai-shim): extract generic and Gemini stream conversion (#2008)
* test(openai-shim): anchor JSON fallback ownership

* refactor(openai-shim): extract stream conversion

* fix(openai-shim): preserve stream completion semantics

* fix(openai-shim): finalize incomplete streams

* fix(openai-shim): reject incomplete tool streams

* test(openai-shim): cover split raw tool text

* fix(openai-shim): reject incomplete fallback tools
2026-07-29 09:39:48 +08:00
0xfandomandGitHub 3925f2791c feat(auth): opt-in loopback proxy hosts that keep subscription (OAuth) auth (#2050)
* feat(auth): opt-in loopback proxy hosts that keep OAuth first-party

Pointing ANTHROPIC_BASE_URL at any host other than api.anthropic.com switches
the client to API-key mode, dropping a signed-in subscription session. That
blocks running the CLI through a local transparent proxy (compression,
inspection, caching) that forwards auth headers to Anthropic unchanged.

Add ANTHROPIC_FIRST_PARTY_PROXY_HOSTS: a comma-separated host[:port] allowlist
that extends first-party detection. It is honored only when the base URL points
at a loopback host, and only loopback entries are considered -- both checks are
redundant by design so a misconfigured non-loopback entry can never widen
first-party status to an off-machine host. Default behavior is unchanged.

Closes #2016

* docs(auth): document ANTHROPIC_FIRST_PARTY_PROXY_HOSTS

* fix(auth): harden loopback proxy allowlist matching

Normalize the base URL port to its scheme default (80/443) before
comparing an explicit allowlist port, so a `127.0.0.1:80` entry matches
`http://127.0.0.1`. Reject embedded credentials and non-http(s) schemes
up front so an OAuth session is never attached to a URL carrying userinfo
or a non-proxy scheme.
2026-07-28 22:39:41 +08:00
0xfandomandGitHub 580a6b1197 fix(sdk): report a permission timeout as a timeout (#2028)
* fix(sdk): report a permission timeout as a timeout

On timeout the handler called denyPendingPermission and then fell through
to the fallback. The deny resolves the promise registered by
registerPendingPermission, but Promise.race has already settled with
{timedOut: true}, so nothing is awaiting it and the decision is discarded.

The fallback is createDefaultCanUseTool, whose contract is that the host
supplied no permission callback at all. A host that wired up
onPermissionRequest and simply answered too slowly therefore got the tool
result 'no canUseTool or onPermissionRequest callback provided. Pass
canUseTool in options', plus the matching warning on stderr -- both false,
and both pointing at a configuration problem that does not exist. It also
consumed the one-shot warning latch, so a genuinely misconfigured later
query in the same process is never warned.

Return the timeout decision directly. The permission_timeout event and the
existing deny are unchanged.

* test(sdk): move the timeout cases into the existing permissions suite

tests/sdk/permissions.test.ts pinned the old behavior -- it asserted the
timeout result was the fallback's message, with a comment describing the
fall-through as intended. It is not: that message claims no permission
callback was provided, which is false whenever onPermissionRequest is
wired up. Assert the timeout reports itself instead.

The new cases live in that suite rather than a new file: a separate test
file adds a slot to bun's sequential file ordering, which shifted which
suite runs before which and surfaced an unrelated mock leak in CI
(taskReport git metadata and the /ads command).

* test(sdk): drive the permission-timeout case off a mocked clock

The no-callback-fallback-on-timeout test relied on a real 10ms wait, so
the deny hinged on scheduling. Use fake timers and advance the clock by
the timeout window instead, making the timer the deterministic cause of
the denial.
2026-07-28 22:38:49 +08:00
3c5856a004 feat(integrations): add Ling 3.0 Flash free to the Opengateway catalog (#2057)
* feat(integrations): add Ling 3.0 Flash free to the Opengateway catalog

inclusionai/ling-3.0-flash:free — 124B MoE reasoning model, 262K
context, 32K max output, tool calling verified through the gateway.
Free window on the gateway runs until 2026-08-03; the gateway delists
it automatically after that.

* test(model): include Ling 3.0 Flash in the Opengateway picker expectation

The static descriptor picker asserts the exact Opengateway catalog;
inclusionai/ling-3.0-flash:free now sits between Nemotron and HY3.

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-28 22:33:47 +08:00
12994f2c97 feat(ui): single-row centered startup logo with ANSI Shadow wordmark (#2053)
* feat(ui): single-row centered startup logo with ANSI Shadow wordmark

Render OPEN and CLAUDE side by side as one centered 6-line block on the
pre-Ink startup screen, redrawn in ANSI Shadow letterforms (consistent
shadow corners, D-shaped D, clean N). Terminals narrower than the 94-col
row fall back to two stacked blocks, each centered as a unit so rows stay
aligned. The tagline, provider box, and version line are centered to
match.

The Ink welcome panel wordmark (constants/brand.ts) becomes a matching
single row: letter-spaced caps flanked by shade-gradient accents,
keeping the shimmer/brand two-tone split.

Adds layout unit tests (one-row vs stacked switchover, block centering,
box centering) and brand wordmark invariant tests; updates the D-shape
glyph regression for the new font.

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

* test(ui): address CodeRabbit review — wordmark render test, row centering asserts

Extract the LogoV2 wordmark row into a WordmarkRow component and add a
focused render test asserting segment order and the shimmer/brand color
split (left accent + OPEN in brandShimmer, CLAUDE + right accent in
brand), via renderToAnsiString with chalk pinned to truecolor.

Extend the startup-screen layout test to assert the tagline and version
rows are centered, alongside the existing provider-box check.

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

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-28 13:51:08 +08:00
JATMNandGitHub 9d5b77db89 fix(query): do not trip tool-failure guard on parallel same-turn failures (#2048)
* fix(query): count parallel tool failures once per turn

Prevent the tool-failure loop guard from stopping a response when a single model turn fans out parallel tool calls that share an error signature, category, or path. Cross-turn accumulation still trips after the model has a chance to adapt.

Fixes #2035

* fix(query): emit one advisory per signature in a parallel batch

Once-per-turn counting left the penultimate advisory path repeating for every duplicate failure in the same batch. Gate advisory emission on newly seen signatures this turn.

* test(query): lock parallel once-per-turn failure counting

Cover same-path parallel batches and assert the post-batch streak so a
partial double-count regression cannot slip past the suite.
2026-07-28 13:45:37 +08:00
JATMNandGitHub ca29d4454f refactor(openai-shim): extract XML and response conversion (#2007)
* refactor(openai-shim): extract XML and response conversion

* fix(openai-shim): align facade tests with XML extraction

* fix(openai-shim): restore shared XML tool-call sequencing

Wire parseXmlToolCalls through the façade sequence counter, reuse the
shared extractBalancedJson helper, and restore production dependency
coverage in conversion and façade tests.

* fix(openai-shim): restore façade coverage and require XML sequencer

Restore non-streaming convert and HY3 JSON-fallback e2e seams so façade
dependency wiring stays exercised, require an injected XML id sequencer,
and assert consecutive shared-sequence ids.

* fix(openai-shim): restore provider coverage and array content guard

Keep relocated openaiShim suite tests in test:provider, and reject
non-object array content parts the same way the inline converter did.

* fix(openai-shim): preserve mixed XML/HY3 tool-call order

Sort HY3 and standard XML candidates by source offset before ID
assignment, isolate focused test sequencers, and restore fetch after
the Gemini non-streaming façade test.
2026-07-28 13:44:56 +08:00
0xfandomandGitHub 83440a6fe6 fix(stats): stop over-counting totalDays by one (#1953)
* fix(stats): stop over-counting totalDays by one

The /stats 'active X of Y days' denominator (totalDays) diffed the first and
last session ISO timestamps raw, ran Math.ceil over the millisecond gap, then
added 1. firstSessionDate/lastSessionDate are full timestamps, so ceil rounds
any sub-day remainder up to a whole day and the +1 double-counts it: all
activity on a single calendar day reported 2 days (50% active instead of
100%), and every non-24h-multiple span was one day long.

Extract inclusiveCalendarDaySpan, which snaps both endpoints to their UTC date
(via toDateString, the same basis activeDays/dailyActivity use) before
differencing, so the gap is an exact multiple of 24h and the inclusive +1 is
correct. Use it at both call sites (cacheToStats and
processedStatsToClaudeCodeStats).

* fix(stats): guard day-span helper against unparseable persisted dates

A same-version stats cache can carry a structurally-valid but unparseable
firstSessionDate (e.g. "not-a-date"). That value reached inclusiveCalendarDaySpan,
where new Date(...).toISOString() throws RangeError and aborts the whole /stats
render instead of degrading. Parse the endpoints up front and fall back to 0
(the same value callers use for a missing endpoint) when either is invalid.

* fix(stats): reject malformed persisted dates instead of trusting Date.parse

Date.parse accepts far more than the shapes this pipeline persists: "2026-07",
"2026", "123" and "01/01/2026" all resolve to real dates, so a truncated or
foreign-format cache value produced a plausible-but-wrong totalDays rather than
being rejected. Require the value to start with a full ISO calendar date — the
two shapes actually written are a session.timestamp instant and a bare
dailyActivity YYYY-MM-DD key — and fall back to 0 otherwise.

* test(stats): guard the off-by-one with spans that separate the formulas

The multi-day, identical-timestamp and adjacent-midnight cases return the same
value under both the old Math.ceil(gap)+1 and the new calendar-day formula, so
they could not catch a regression. Add the canonical failing spans (1.5 and 3.5
raw days, where the old formula reported 3 and 5 instead of 2 and 4), a bare
dailyActivity date-key case, and the malformed-date rejections.

* fix(stats): reject impossible calendar dates, not just non-date shapes

The corruption guard only checked for a date-shaped prefix, so Date.parse still
normalized impossible values — 2026-02-30 parsed as March 2 and
inclusiveCalendarDaySpan('2026-02-30', '2026-03-02') returned 1 instead of the
documented 0 fallback, letting a corrupt cache date fabricate the /stats
denominator. Validate the spelled year/month/day against the real calendar
(leap years included) before trusting the parse. Regression covers rollover,
month/day bounds, and Feb 29 in leap vs non-leap years.

* fix(stats): accept only the two persisted timestamp shapes

The prefix check also matched a space-delimited value such as
'2026-07-13 23:30:00', which Date.parse then read as a host-local
timestamp. That is neither a bare dailyActivity date key nor an ISO
instant emitted by the pipeline, so the computed span depended on the
machine's timezone — inclusiveCalendarDaySpan('2026-07-13 23:30:00',
'2026-07-14T00:30:00.000Z') returned 2 under UTC and 1 under
America/Los_Angeles instead of the documented 0 fallback for corrupt input.

Anchor the pattern at both ends and require the zone designator on the
instant form.

* fix(stats): select session endpoints chronologically and reject bad clocks

Both aggregation paths picked firstSessionDate/lastSessionDate by string
comparison, but offset-qualified instants do not sort that way:
2026-07-13T23:30:00-10:00 is later than 2026-07-14T00:00:00+14:00 while
sorting earlier, so it was chosen as the first endpoint and the span came
out 0 for two sessions that occupy different UTC days. Compare parsed
epochs, falling back to string order only for values that do not parse so
selection stays total.

The persisted-date guard also let out-of-range clock components through.
Date.parse normalizes 2026-07-13T24:00:00.000Z to midnight on July 14, so
a corrupt timestamp fabricated a day of span instead of taking the
documented 0 fallback. Validate hours/minutes/seconds and the offset the
same way the calendar components are already validated.

* fix(stats): order the cached first session chronologically too

The cache writer is the companion to the endpoint selection this PR fixed,
and it had the same lexical comparison. Offset-qualified timestamps do not
sort by the instant they denote, so merging 2026-07-14T00:00:00+14:00 (UTC
July 13) with 2026-07-13T23:30:00-10:00 (UTC July 14) stored the later one
as the cache's first session. A later cached /stats run has no session list
left to correct that, so it reports one total day for activity spanning two
UTC dates.

The persisted-date helpers move to statsCache.ts and are re-exported from
stats.ts: the cache writer needs them, and stats.ts already depends on that
module, so importing the other way would be a cycle.

Also assert the exact two-day span in the offset test instead of merely a
positive result, which a regression to 1 would have passed.

* fix(stats): heal a corrupt persisted firstSessionDate seed

Both first-session selection loops seed firstSessionDate from the persisted
cache, which can hold a corrupt value. A garbage seed that sorts lexically
before every real timestamp (e.g. "1") is never displaced by
comparePersistedDates, so the corruption -- and the wrong totalDays it drives
-- persists across every later run. Treat an unparseable seed as absent so
the first valid session date replaces it.

* fix(stats): accept the ISO spellings the ingestion path persists

parsePersistedDateMs required seconds and a colon in the numeric offset, so it
returned NaN for valid instants that processSessionFiles stores verbatim --
`2026-07-13T12:00Z` and `...+0000`. The session was still counted in
dailyActivity but the span came out 0, so /stats reported zero total days for
real multi-day activity. Make the seconds group and the offset colon optional
while keeping the zone requirement and range/calendar validation. Also use the
explicit leap rule instead of Date.UTC(year, ...), which maps years 0-99 to
1900-1999 and judged year 0000 inconsistently with the Date.parse result.
2026-07-28 13:44:07 +08:00
158bdd0dcf docs(readme): rename Sponsors to Partners, add AI/ML API and Novita AI, new wordmark (#2054)
- Rename the Sponsors section and nav link to Partners
- Add AI/ML API and Novita AI to the partners table with local logo
  assets; AI/ML API ships light/dark SVG variants behind a <picture>
  element so the wordmark stays readable on both GitHub themes
- Replace the green SVG header wordmark with the orange pixel-art
  OPENCLAUDE wordmark PNG

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-07-28 13:31:10 +08:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
a3c251f77f chore(main): release 0.26.0 (#2025)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v0.26.0
2026-07-27 12:13:38 +08:00
JATMNandGitHub 53d3cf7f1d fix: apply ultrathink effort to provider requests (#2046)
* fix(query): apply ultrathink effort to API requests

* fix(attachments): honor opt-out for speculative effort

* test(attachments): isolate ultrathink feature mock
2026-07-27 09:43:55 +08:00
JATMNandGitHub a65201396a fix(cache-probe): omit unsupported cache request fields (#2044)
* fix(cache-probe): omit OpenAI cache fields on compatible APIs

* fix(cache-probe): preserve Azure cache extensions

* fix(cache-probe): support Azure-style cache probes

* test(cache-probe): type fetch mock correctly

* test(cache-probe): cover supported cache payloads

* fix(cache-probe): support responses compatibility payloads
2026-07-27 09:43:19 +08:00
JATMNandGitHub 0652f0524b refactor(openai-shim): extract tool conversion (#2006)
* test(openai-shim): mark extraction ownership seams

* refactor(openai-shim): extract tool conversion and parsing

* fix(openai-shim): harden extracted tool helpers

* fix(openai-shim): normalize parsed text tool arguments

* fix(openai-shim): require object text tool arguments
2026-07-27 08:59:32 +08:00
a3dc345f12 test(user): restore real modules from a pre-mock snapshot (#2031)
* test(user): restore real modules from a pre-mock snapshot

This suite's teardown re-installed its own mocks instead of undoing them.

`import * as realExeca from 'execa'` is a live namespace binding, and
mock.module repoints it. By the time afterEach ran, `realExeca` WAS the
mock, so `mock.module('execa', () => realExeca)` reinstalled the stub -- and
mock.module lasts for the life of the process, so every test file loaded
afterwards got it.

The stub returns { exitCode, stdout } with no stderr, which is what made it
visible elsewhere: collectTaskReportGitMetadata does
`inside.stderr.trim()` and threw "undefined is not an object". The two
task-report CLI handler tests and the two /ads command tests failed on any
run where this file happened to be ordered before them, which is why the
same four went red on unrelated PRs and intermittently on main itself
(6bef0e16, 0ff1d1cb).

Snapshot each module surface into a plain object at load, before any mock is
installed, and restore through the snapshots. The stub definitions build on
the snapshot too -- a bare `import('execa')` inside the helper resolves to
whatever mock is current, so each stub was being layered on the last.

* chore(test): drop stray VCR fixture from mock-teardown fix

The fixtures/734ad7.json capture was accidentally recorded while running
the SDK suite locally and is unrelated to the mock-teardown repair. It
replays an empty response for the 'test undefined reason' lifecycle path
(hiding regressions) and embeds an environment-dependent agent-listing
reminder. Remove it to keep this PR focused.

* test: harden user mock teardown and stabilize interrupt lifecycle

Use win32 for the analytics platform mock (env.Platform contract) and
include stderr on the async execa stub so a future leak fails soft.

Rewrite the undefined-reason interrupt lifecycle assertion onto the
deterministic queryLoop + stop-hook path so it no longer depends on an
empty VCR fixture or SDK model-startup races after fixture removal.

* test(sdk): drop duplicate stop-hook default-abort lifecycle clone

The rewritten "undefined reason" interrupt test was an exact copy of the
existing Stop-hook default-abort regression in the same file. Keep the
single deterministic coverage path.

---------

Co-authored-by: jatmn <the@jat.mn>
2026-07-26 12:54:45 +08:00
JATMNandGitHub 3f443414c5 refactor(openai-shim): extract message content conversion (#2005)
* refactor(openai-shim): extract message conversion

* test(openai-shim): complete message conversion test extraction

* test(openai-shim): preserve text-only image guard

* test(openai-shim): retain main coverage after extraction

* fix(openai-shim): guard malformed content blocks
2026-07-24 08:54:39 +08:00
0xfandomandGitHub 62d15d40a6 fix(commands): insert slash-command argument text literally, not as regex refs (#1966)
* fix(commands): insert slash-command argument text literally, not as regex refs

substituteArguments filled $ARGUMENTS and named-$foo placeholders by passing
the user's argument string as the replacement operand of String.replace/
replaceAll. When the replacement is a string, JS interprets $-sequences in it
($$, $&, $`, $', $n), so argument text containing those was mangled: `$100$$`
lost a dollar, `$&` re-inserted the matched placeholder, etc. The indexed
($ARGUMENTS[n], $n) paths right beside these already use function replacers and
were unaffected. Switch the two string-replacement sites to function replacers
so the value is inserted verbatim.

* fix(commands): stop later placeholder passes from rewriting inserted values

Each substitution pass wrote into the same content the following passes then
re-scanned, so an argument value that legitimately contained a placeholder token
was substituted twice: with args '"$1" second', a $name (or $ARGUMENTS[0]) whose
value is the literal $1 came back as 'second' instead. Park each substituted
value behind a salted, NUL-delimited slot token and swap the real values back in
after every pass has run, so a value is only ever inserted, never interpreted.

* fix(commands): write the slot-token NUL delimiter as an escape, not a raw byte

The slot template and restoration regexp contained literal 0x00 bytes, which
makes Git classify the whole tracked file as binary — git diff can only say the
file differs and GitHub cannot render or review changes to it. Spell the
delimiter as \x00 escapes; the evaluated strings are unchanged, so the tokens
remain NUL-delimited at runtime while the source stays text.
2026-07-24 08:53:45 +08:00
Xiang HanandGitHub c23b6e1c64 fix(query): keep long-running tools active (#2022)
* fix(query): keep long-running tools active

* test(mcp): cover silent-server heartbeat in activity regression

* fix(mcp): guard heartbeat callbacks and merge partial server progress

* fix(ui): treat waiting_for_task heartbeats as ephemeral progress

* fix(mcp): guard terminal progress callbacks in tool call path

* fix(mcp): guard forwarded progress and reset cache on session retry

* fix(agent): guard forwarded subagent progress and reset cache on elicitation retry

* fix(mcp): clear progress before URL elicitation wait

* fix(mcp): contain started progress callback failures
2026-07-24 07:28:12 +08:00
JATMNandGitHub 01a01fb033 fix(ui): show streaming token count immediately (#2030)
* fix(ui): show streaming token count immediately

* test(ui): cover reduced-motion token override
2026-07-23 10:47:23 +08:00
022f057a3c feat(aimlapi): add passwordless client methods and response-shape guards (2/N) (#2020)
* feat(aimlapi): add passwordless client methods and response-shape guards

* fix(aimlapi): surface malformed auth and key responses as AimlapiApiError

* fix(aimlapi): redact error bodies and tighten checkout and account guards

* fix(aimlapi): redact submitted credentials and complete response guards

* fix(aimlapi): treat any successful non-JSON acknowledgement as delivered

* test(aimlapi): assert the sign-in code request contract

* fix(aimlapi): redact escaped, overlapping and cancelled-request secrets

---------

Co-authored-by: Lookoff123 <bataryshkinairina@gmail.com>
2026-07-23 08:19:34 +08:00
JATMNandGitHub 6bef0e1604 refactor(openai-shim): extract Ollama adapter (#2004) 2026-07-23 07:18:25 +08:00
0xfandomandGitHub 0ff1d1cb7b fix(memory): match nested directories on path boundaries, not name prefixes (#1974)
* fix(memory): match nested directories on path boundaries, not name prefixes

getDirectoriesToProcess documents nestedDirs as "Directories between CWD and
targetPath", but tested containment with currentDir.startsWith(originalCwd).
A sibling whose name merely begins with the CWD's name satisfies that: with cwd
/work/myapp, reading /work/myapp-backend/src/a.ts collected /work/myapp-backend
and its subdirectory, so their CLAUDE.md loaded as Project memory. Renaming the
directory to /work/backend loads nothing — same layout and same permission
grant, different behavior purely because of how the name is spelled.

Route the check through pathInWorkingPath, the helper already used for path
containment elsewhere in this file's module graph, so the comparison happens on
path boundaries.

* test(memory): build nested-dir fixtures with path helpers for Windows

getDirectoriesToProcess resolves its inputs, so on Windows the outputs carry a
drive letter and backslashes; hardcoded POSIX fixture strings would never match
and the walk-to-CWD comparison could not terminate. Construct every fixture and
expectation with resolve/join so they follow the platform.

* fix(memory): use native case semantics for the nested-directory check

pathInWorkingPath case-folds both operands on every platform so that
case-variant spellings cannot slip past a permission check. That is the
wrong direction for memory traversal: on a case-sensitive filesystem
/work/MyApp and /work/myapp are two unrelated projects, and folding them
together made getDirectoriesToProcess('/work/myapp/src/a.ts', '/work/MyApp')
return the /work/myapp ancestors, loading the other project's
CLAUDE.md/AGENTS.md as nested project memory.

Use a local boundary check built on relative(), which keeps the platform's
native case semantics while still comparing on path boundaries rather than
string prefixes.

* fix(memory): compare the relative path on segment boundaries

The containment check used rel.startsWith('..'), which is the same
string-prefix mistake this PR set out to fix: a directory legitimately named
'..hello' yields the relative path '..hello', so a genuinely nested
directory was dropped and its CLAUDE.md never loaded.

Match '..' exactly or followed by a separator instead, and add a regression
for the dotted-name case.

Also skip the case-variant assertion on Windows: path comparison there is
case-insensitive, so /work/MyApp and /work/myapp really are the same
directory and treating them as nested is correct.

* fix(memory): keep directory containment case-faithful on Windows

path.win32.relative() compares components case-insensitively, so it
returns "src" for C:\\work\\MyApp -> C:\\work\\myapp\\src. NTFS supports
per-directory case sensitivity, so those can be distinct project trees,
and the containment check would load the other project's CLAUDE.md and
rules as nested memory for a session rooted at the first.

Rebuild the child from the parent and compare exactly: the boundary logic
relative() provides is kept, the lexical case distinction is restored. The
path implementation is injectable so the Windows semantics are covered on
every host rather than skipped outside Windows.
2026-07-23 07:17:47 +08:00
JATMNandGitHub df85369b60 refactor(openai-shim): extract provider compatibility (#2003)
* test(openai-shim): mark extraction ownership seams

* refactor(openai-shim): share executor planner retry state

* test(openai-shim): add stable extraction seams

* refactor(openai-shim): extract provider compatibility

* fix(openai-shim): honor disabled NIM thinking
2026-07-22 22:14:47 +08:00
SuyunandGitHub 5f6c60851a fix(fs): tolerate EPERM from mkdir on Windows drive roots (#2026)
On Windows, writing a file directly at a drive root (e.g. writing
content to D:\foo via the Write tool) fails with EPERM: dirname('D:\foo')
is 'D:\' itself, and mkdir('D:\', { recursive: true }) always fails —
the kernel cannot create a root that already exists, and libuv maps
that to EPERM rather than EEXIST. The error propagated to the Write
tool as a spurious 'permission denied' on mkdir D:\.

Treat EPERM like EACCES in NodeFsOperations.mkdir/mkdirSync: swallow it
only when the directory already exists. Genuine permission failures
still propagate — the existsSync guard limits the no-op to cases where
there was nothing to create, and a present-but-unwritable directory
still fails at the subsequent file write.

The regression tests use spyOn + mock.restore() rather than
mock.module(): module mocks are process-global and leak across test
files in the same bun process (neither mock.restore() nor
re-registering the real module clears them in bun 1.3.x).
2026-07-22 21:54:50 +08:00