* feat(aimlapi): attribute MCP requests to mcp.aimlapi.com
Requests from any MCP server whose URL is https on the exact hostname
mcp.aimlapi.com now carry X-AIMLAPI-Source and X-AIMLAPI-Partner-ID,
resolved through the existing resolveAimlapiAttributionHeaders helper so
a configured header cannot replace the partner id. The hook sits in
getMcpServerHeaders, so it covers SSE/HTTP transports for both a
hand-added server and a plugin-provided one. Other MCP servers and
look-alike hosts keep exactly the headers they were configured with.
* feat(aimlapi): opt-in built-in plugin for the AI/ML API MCP server
Add the aimlapi@builtin plugin, which provides the remote MCP server
https://mcp.aimlapi.com/mcp (Toolbox tools_*, image/video/audio
generation, model and account tools). It follows the karpathy-guidelines
precedent: defaultEnabled is false, so the user turns it on explicitly with
`openclaude plugin enable aimlapi@builtin` or the /plugin UI, and a present
key never connects a billed server on its own.
The server authenticates with `Authorization: Bearer ${AIMLAPI_API_KEY}`,
an environment reference expanded at load time, so the key is never
written to settings or an MCP config. An active aimlapi.com profile already
exports its saved key under that name. The plugin is hidden when no key is
set, or when the aimlapi route points at a non-canonical (proxy) base URL,
whose credential the profile exports under the same name.
`openclaude mcp list` now registers built-in plugins first, like the skills
subcommands do, so it lists and health-checks the server the way a session
sees it. Docs cover the plugin and the zero-code `openclaude mcp add`
commands (API key header or OAuth).
* test(aimlapi): pin settings in the MCP plugin test
Bun runs test files in a shared process, and StartupScreen.test.ts leaves its
settings.js mock registered (mock.restore() does not undo mock.module). In the
shard where that file runs before aimlapiMcp.test.ts, getSettings_DEPRECATED
always returned {}, so the opt-in plugin never showed as enabled and two tests
failed.
Mock settings.js in this file the way ModelPicker.test.tsx does: pin
getSettings_DEPRECATED to the test's own settings, restore the real module
after each test, and hold the shared mutation lock while the mock is active.
The plugin code is unchanged.
* fix(aimlapi): send one key to the MCP server when AIMLAPI_API_KEY is a pool
A provider profile can export a comma-separated credential pool as
AIMLAPI_API_KEY, so `Bearer ${AIMLAPI_API_KEY}` could expand to several keys
in one header. For the mcp.aimlapi.com host only, resolveAimlapiMcpHeaders now
keeps the first usable key of a bearer pool (firstUsableCredential), the
same rule #2465 uses for web search. Single keys, non-bearer values and every
other MCP server are left unchanged.
* fix(aimlapi): gate the MCP plugin on a usable key and the OPENAI_API_BASE alias
hasAimlapiMcpCredential now uses firstUsableCredential, so a placeholder
value such as `null` no longer makes the plugin available, and it treats
OPENAI_API_BASE like OPENAI_BASE_URL when withholding a proxy profile's key.
Tests cover the alias, the placeholder and a pooled key reaching the server
as a single Bearer token.
* docs(aimlapi): note the alias and first-key pool rules for the MCP plugin
Mention OPENAI_API_BASE in the proxy rule and that a pooled AIMLAPI_API_KEY
sends only its first usable key to the MCP server.
* test(aimlapi): build startup regression from current source
* fix(aimlapi): normalize single MCP credentials and activation guidance
---------
Co-authored-by: Stan <stan@aimlapi.com>
Co-authored-by: Hugo <hugo@aimlapi.com>
* feat(web-fetch): add an opt-in AI/ML API Toolbox scraping backend
WebFetch can already replace its raw HTTP fetch with Firecrawl's scrape
endpoint. This adds the AI/ML API Toolbox as a second scraping backend,
selected with WEB_FETCH_PROVIDER=aimlapi and authenticated with the
existing AIMLAPI_API_KEY.
Toolbox runs are billed per page, so the backend is opt-in only: an
AI/ML API key on its own never changes how WebFetch fetches pages. When
selected it takes precedence over a Firecrawl key.
- Runs the search1api/crawl tool, chosen by measurement against the
other page-to-Markdown tools ($0.0013 per run, main-content Markdown
with links, unbilled is_error runs for missing pages), with
max_cost_usd capped at $0.005.
- Polls queued (202) runs, reports is_error runs and over-cost
refusals as WebFetch errors, and retries 429/502/503/504 with the
same idempotency key so a retry is never charged twice.
- Sends the key and the X-AIMLAPI-Source / X-AIMLAPI-Partner-ID
attribution headers only to the fixed tools.aimlapi.com endpoint.
- Like the Firecrawl path, a failed scrape is reported rather than
retried over plain HTTP.
* fix(web-fetch): never send an aimlapi proxy profile's key to the Toolbox
An aimlapi provider profile that points at the user's own proxy exports
the proxy's credential as AIMLAPI_API_KEY. The WebFetch Toolbox backend
now treats that key as not configured when the route is aimlapi and
OPENAI_BASE_URL (or OPENAI_API_BASE) is not the canonical
https://api.aimlapi.com/v1, so it is never sent to tools.aimlapi.com.
* fix(web-fetch): match the search backend's credential and redirect handling
Apply the same hardening #2465 gave the AI/ML API web search backend:
- send only the first usable key of a comma-separated AIMLAPI_API_KEY pool
(firstUsableCredential), instead of the whole list as one Bearer token;
- treat the OPENAI_API_BASE alias like OPENAI_BASE_URL when deciding that an
aimlapi profile points at a proxy, so its credential is withheld;
- send both Toolbox requests with redirect: 'error', so the key and the
attribution headers never reach another host;
- redact the active key from Toolbox error text before it is shown.
Tests cover each case.
* docs(web-fetch): describe the requested cost limit and first-key pool use
Word the $0.005 max_cost_usd as a limit each run requests, as the merged
search docs do, and note that a pooled AIMLAPI_API_KEY contributes only its
first usable key.
* docs(web-fetch): document the alias, pool and redirect rules in the setup guide
Describe the requested $0.005 cost limit, the OPENAI_API_BASE alias in the
proxy check, first-key use of a pooled AIMLAPI_API_KEY, and that Toolbox
requests never follow redirects.
* test(web-fetch): keep the backend test's model stub and env changes local
Replace the process-global mock.module() of services/api/claude.js with a
spyOn() on queryHaiku, which mock.restore() removes, so test files that run
later in the same process get the real queryHaiku again. Calls are counted
on a local stub, since the spy reuses an earlier file's leftover module mock.
Snapshot the fixture's variables with snapshotEnv() after taking the shared
mutation lock and clear them, including CLAUDE_CODE_PROVIDER_ROUTE_ID,
OPENAI_BASE_URL and OPENAI_API_BASE, so an ambient aimlapi proxy profile no
longer withholds the test key. They are restored before the lock is released.
* fix(env-file): accept AIMLAPI_API_KEY in --provider-env-file
.env.example lists AIMLAPI_API_KEY for the file-based setup, and the AI/ML
API WebFetch backend pairs it with WEB_FETCH_PROVIDER, but the loader
rejected the key as unsupported. Allow it, and cover loading both values.
* test(web-fetch): restore inherited model mock after backend calls
* docs(web-fetch): clarify retry scope and external URL disclosure
---------
Co-authored-by: Stan <stan@aimlapi.com>
Co-authored-by: Hugo <hugo@aimlapi.com>
* fix(update): restore getLatestVersion in published build
Remove the utils/autoUpdater stub block from no-telemetry-plugin.ts
that replaced getLatestVersion() with a no-op returning null, causing
'openclaude update' to fail with 'Failed to check for updates'.
The real implementation runs 'npm view' (5s timeout) with a direct
registry.npmjs.org HTTP fallback. The 9 remaining telemetry/analytics
stubs are preserved.
Fixes#2424
* fix(update): pass selected channel version and close lock race
- Pass latestVersion to installGlobalPackage in the standalone global
CLI path so stable-channel installs install the resolved version
instead of @latest.
- Check isAutoUpdaterDisabled before getLatestVersion in the background
AutoUpdater callback so CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
suppresses lookups at mount and on the 30-minute timer.
- Make acquireLock atomic: verify owning PID is alive before stale
recovery, and write to a unique temp file then rename instead of
stat/unlink/write, closing the TOCTOU race that admitted two owners.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(update): preserve exclusive locks and package manager ownership
* test(update): use a file URL for the Node preload
* fix(update): retain uncertain installer ownership and emulate Windows commands
* fix: preserve safe updater recovery after failed termination
* fix(update): retain ownership after installer signal termination
* test(update): preserve lifecycle worker on Windows
* fix(test): bound updater fixture readiness wait
---------
Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: jatmn <the@jat.mn>
* feat(sdk): let a v2 session carry its own environment
`QueryOptions` accepts `env`; `SDKSessionOptions` did not, so a host that
keeps several sessions alive in one process had to share one environment
between them.
That matters when the variable selects the provider. `ANTHROPIC_BASE_URL`,
`OPENAI_BASE_URL` and the matching keys are read per request, so two sessions
aimed at different endpoints silently used whichever had been set last — and
the symptom is not an error but an answer from the wrong place, or billing
against the wrong account.
Applied around each turn and restored afterwards, reusing the process-wide env
mutex `query()` already takes (SEC-1), so one session's variables cannot leak
into another session's turn. The wrapping sits around the whole iteration
rather than inside the generator, so a consumer that breaks out of the loop
still restores: the generator's return runs the finally with it. A session
that sets no `env` takes no mutex and costs nothing.
The mutex means turns belonging to sessions with different environments
serialise rather than overlap. With one process environment there is no honest
alternative, and serialising is the behaviour that is correct rather than
merely concurrent; the type documents it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(sdk): apply a session's env after init, and never hold the mutex on failure
Review follow-up on the placement and on two ways the lock could be kept.
APPLIED AFTER init(), INSIDE THE GENERATOR. `query()` takes the mutex and
applies its overrides after `init()` for a reason: `init` is memoized, and it
configures the global agents, the proxies and mTLS once for the process. Doing
it before meant whichever session happened to run first configured all of that
out of its own variables. The apply now sits where query's does — right after
init, with the restore in a `finally` around the rest of the turn.
An early exit still restores. That was the stated reason for wrapping `yield*
inner` instead, and it was not a reason: a consumer that breaks out of the loop
calls the generator's `return`, which runs the generator's own `finally` — the
way the same pattern already works in `query()`.
THE MUTEX IS RELEASED IF APPLYING THROWS. `Object.entries(overrides)` invokes
getters, so an `env` whose properties are accessors could throw after the lock
was taken and before `sendMessage` entered its `try`. The lock stayed held and
every later env-setting turn in the process waited on it forever. Application
is now wrapped: what was already applied goes back and the lock is released
before the error leaves.
THE SNAPSHOT HAS A NULL PROTOTYPE. `__proto__` is a variable name like any
other, and on a plain object `snapshot['__proto__'] = value` is swallowed by
the prototype setter instead of being remembered — so the one key that must be
restored would be the one key missing from `Object.keys(snapshot)`.
The tests now drive `sendMessage()` itself, not only the private apply and
restore: the engine is a stand-in that reports what it sees in `process.env`,
and `init` is stubbed because the real one reaches the network. They prove the
variables are in place when the engine asks, that an abandoned iterator puts
them back and frees the lock, and that two turns with different environments
never interleave — three tests, all five of the earlier ones kept, red against
main (0 of 8).
Documented in the type what this does NOT do, since it would otherwise be
found out instead: a turn of a session without `env` takes no mutex, exactly as
in `query()`, so it can run while another session's variables are in place.
Isolating readers too would mean serialising every turn in the process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(sdk): say how a session's env can deadlock, since the lock is held all turn
Review follow-up. Holding the environment mutex for the whole turn is what
makes the isolation real, and it has two consequences a host author would
otherwise meet as a hang:
A PAUSED ITERATOR STILL HOLDS IT. Stopping half way through `for await (… of
session.sendMessage(…))` to await another env-carrying turn waits forever: the
first turn cannot finish while the second waits for the lock it holds.
Finishing the turn, or leaving the loop with `break`/`return` — which calls the
generator's `return` and frees the lock — is the way out.
A `canUseTool` CALLBACK IS INSIDE THE TURN. Awaiting an env-carrying `query()`
from it is the same deadlock one level down.
Both are documented on the option rather than discovered. Neither applies to a
session without `env`, which takes no lock at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(sdk): enumerate a session's env once, and say what an env-less turn sees
Three findings from review.
Object.keys fed the snapshot loop and Object.entries the apply loop. A plain
host object gives both the same keys; a Proxy need not. A key seen only by the
apply loop would be set and never restored. Enumerated once now, and before
the mutex is taken, so a throwing getter cannot leave it held either.
mock.module registrations are process-global and mock.restore does not remove
them, so the stubbed init() went on standing in for the real one in every test
file that ran afterwards in the same bun process. The real module is captured
first and re-registered in afterAll.
The review also asked for a concurrent env-less turn, expecting isolation.
There is none, and the test now says so: a turn that sets no environment takes
no mutex, so it runs alongside one that does and reads the same process.env
while the variables are in place. Serialising every turn instead would stall
an embedder's whole process the moment one session used env. What does hold —
that nothing survives the turn — is asserted, and the limit is documented on
SDKSessionOptions.env.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(sdk): restore session environment across provider and close paths
* test(sdk): run Node environment regression through package harness
* test(sdk): rebuild current sources before runtime assertions
* fix(sdk): release closed session references immediately
* fix(sdk): restore authentication state between session turns
* fix(sdk): invalidate helper credentials at session env boundaries
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: jatmn <the@jat.mn>
* feat(web-search): add AI/ML API Toolbox as an explicit search backend
Add an `aimlapi` web search provider that runs the AI/ML API Toolbox tool
`web/search` (POST https://tools.aimlapi.com/v1/tools/run). It reuses the
key the AI/ML API model provider already has (AIMLAPI_API_KEY, or the
route's OPENAI_API_KEY fallback while OPENAI_BASE_URL is the canonical
AI/ML API endpoint), so a user on that provider has nothing to paste.
Every run is billed to the user's AI/ML API balance, so the backend is
explicit-only: it is not in the auto chain, and an AIMLAPI_API_KEY alone
never routes searches through it. Each run carries max_cost_usd 0.025.
- Map output.results[] (url/title/snippet/published_at) onto search hits,
prefixing the snippet with the publication date.
- Send allowed_domains as include_domains; apply both domain lists to the
results like the other backends (the tool has no exclude field).
- Poll GET /v1/tools/runs/{id} after a 202 until the run ends, within the
web search timeout; report is_error runs and HTTP error codes.
- Send the key and the X-AIMLAPI-Source / X-AIMLAPI-Partner-ID attribution
headers only to tools.aimlapi.com, checked by exact hostname.
- /search aimlapi selects it only when the key is present and states the
billing; /search key|remove-key aimlapi refuse, because saving
AIMLAPI_API_KEY to the global env would also switch the model provider.
- Document the backend next to the other search providers.
* fix(web-search): never send a proxy profile's key to the AI/ML API Toolbox
An aimlapi provider profile that points at the user's own proxy exports
the proxy's credential as AIMLAPI_API_KEY, with
CLAUDE_CODE_PROVIDER_ROUTE_ID=aimlapi and OPENAI_BASE_URL set to the proxy.
The search provider returned AIMLAPI_API_KEY unconditionally, so
/search aimlapi would have sent that proxy credential to tools.aimlapi.com.
Resolve no key in that case: the backend reports itself as not
configured, /search aimlapi refuses the selection, and a search fails
with the not-configured error before any request is made.
* fix(web-search): link the published AI/ML API setup page from /search
The npm package does not ship docs/, so the /search key|remove-key aimlapi
message pointed at a path an installed user cannot open. Link the page on
GitHub instead and update the matching test expectation.
* fix(web-search): refuse redirects on Toolbox requests and pin the cost cap
Both Toolbox requests carry the key and the attribution headers, but the
hostname check only covered the first URL. Send them with redirect: 'error'
so no redirect hop can receive the headers, and assert that in the run and
poll tests plus a new test where a redirect fails after a single request.
Pin the documented $0.025 max_cost_usd in the request-body test instead of
comparing against the exported constant.
* fix(web-search): redact echoed AI/ML API credentials in search errors
* docs(web-search): describe the requested AI/ML API cost limit
* docs(web-search): clarify variable pricing and credential reuse
* fix(web-search): honor AI/ML API endpoint alias
* test(search): isolate provider credentials per test
* fix(web-search): reuse individual AI/ML API pool credentials
---------
Co-authored-by: Stan <stan@aimlapi.com>
Co-authored-by: Hugo <hugo@aimlapi.com>
* fix(sdk): follow switchSession with the session file pointer
Project.sessionFile is resolved once and cached for the life of the
process. Every CLI path that changes the active session resets it by
hand straight afterwards - sessionRestore.ts, /clear and print.ts all
call resetSessionFilePointer right after switchSession, and its own
docstring says that is when to call it.
The v2 SDK cannot: unstable_v2_createSession hands back a session object
and the switch happens inside sendMessage, leaving the embedder no moment
to hook. So a second session created in one process went on writing into
the FIRST session's file. Its records carried their own sessionId, so
nothing looked wrong until something tried to resume it -
resolveSessionFilePath found no file, the resume came back with zero
prior messages, and the conversation was gone.
Measured in an embedder: one project directory, one .jsonl, and inside it
three sessions' records - 3979, 917 and 348 - while two of the three had
no file of their own. An interrupt resumed the newest and silently
started from nothing.
Subscribing to the existing onSessionSwitch signal resets the pointer
only on a real change, because v2 calls switchSession before every turn
with the id it already has and resetting each time would drop buffered
entries and re-resolve the same path for nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(sdk): assert the first session keeps its own records
Review follow-up. The test showed that the second session's records do not
leak into the first session's file, but not that the first session still
holds its own - so a change that dropped them both would have passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(sdk): give up the switch subscription with the Project singleton
Review follow-up. resetStateForTests() calls sessionSwitched.clear(),
which drops every listener at once. Remembering only "I am subscribed"
left this module believing it still followed switches while its listener
was gone, and since test files share one bun process that silently
stopped pointer-following for every test afterwards - failing the switch
tests in a full-suite run and nowhere else.
onSessionSwitch returns an unsubscribe function, so resetProjectForTesting
now gives the subscription up and forgets it, and the next getProject()
registers afresh. A stale unsubscribe against an already-cleared signal is
a no-op, which heals the resetStateForTests() case as well as its own.
The added test reproduces that order: create the Project, clear the
signal, let the file's own setup run again, then check a second session
still gets its own transcript. With the unsubscribe neutralised it is the
only test that fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(sdk): follow the project directory with the session file pointer
Three findings from review.
switchSession(id, projectDir) sets both and emits only the id, so an id-only
guard let a directory change past and left the pointer aimed at the old
directory's transcript. Both SDK paths do exactly that: v2 switches with the
session's own project dir before every turn, and query() with the resolved
transcript dir. The pointer now tracks the directory alongside the id, read
from getSessionProjectDir() inside the listener since the signal carries only
the id.
The test cleanup handed switchSession a null project directory, which would
move the active session's transcript for every test that ran afterwards. It
now restores what beforeEach saved.
And the same-id case had no coverage for what it exists to protect: the
previous test flushes first, so nothing was buffered left to lose. The new one
records an entry before the file is materialized, performs the same-id switch
v2 makes before every turn, and asserts it survives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(sdk): scope session metadata and buffered entries to their session
Two findings from review, both process-wide Project state leaking across
sessions.
The currentSession* caches are documented as "current session only", but
resetSessionFile() did not clear them, so when the next session materialized
its file reAppendSessionMetadata() wrote the previous session's lastPrompt
(and any cached title/tag/agent fields) into it. The CLI already worked
around this by hand - REPL.tsx calls clearSessionMetadata() before
restoreSessionMetadata() - but the SDK has no restore to put anything back.
resetSessionFile() now clears the caches itself, so every caller follows the
same rule.
Buffered entries (recorded before the first user/assistant message) lived in
a single slot that resetSessionFile() dropped, so a session that had buffered
an attribution snapshot lost it when another session's turn switched away -
and found it empty if it returned. They are keyed by session now, and only
the materializing session's buffer is flushed; a deleted session drops its
own.
Tests are red-first: with sessionStorage.ts reverted, the two new cases fail
and the rest pass. Refs #2267.
* fix(session): preserve deferred startup metadata on resume
* fix(session): isolate deferred state across transcript destinations
---------
Co-authored-by: seraf <sv.kulikov93@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat: add Keenable as an explicit web search backend
Adds a Keenable provider selectable with /search keenable or
WEB_SEARCH_PROVIDER=keenable. It works without a key through the keyless
endpoint and uses the keyed endpoint when KEENABLE_API_KEY is set. Keenable
stays out of the auto chain, so existing search order does not change.
* fix(web-search): fall back to the Keenable description when a result has no snippet
* test(web-search): type the description-fallback fetch mock like its neighbours
The zero-argument mock did not overlap Bun's fetch type and failed bun run typecheck:fast with TS2352. It now takes the typed request arguments the other mocks in the file use.
* fix: address five AI quality findings
* fix: preserve search and session boundaries during reset
* fix: reset chat interaction and invalidate filtered search results
* fix: keep deep search diagnostics free of conversation content
* fix: preserve resume readiness and deep search result boundaries
* fix: show retryable search failures without logging model output
* fix: describe the background search model accurately
* docs: clarify deep search excerpt limits and data scope
* fix: keep search hydration diagnostics private
* fix: resolve all existing ESLint any warnings
* fix(sdk): preserve raw shapes with _input fields
* fix(sdk): preserve interface inputs and mutable raw arguments
* fix(sdk): enforce callback variance at tool creation
* fix(sdk): preserve raw input and JSON handler compatibility
* fix(sdk): preserve handlers for keyless JSON schemas
* fix: restore original SDK boundary for lint cleanup
* fix(smart-routing): stop overriding sub-agent models chosen by agentRouting
Smart routing classified the first pass of every query loop, including
the loop each sub-agent runs. A sub-agent's prompt is not a user turn, but
it was classified as one and the pinned model overwrote the model
agentRouting (or the agent definition) had just selected. With both
features on, every routed sub-agent silently ran on the strong model.
decideTurnModel now returns routed:false when the query loop belongs to a
sub-agent, so the parent turn's decision holds for its Agent tool call as
documented, and sub-agent runs no longer count toward the routing tally
or the per-session allowlist disable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(smart-routing): cover sub-agent query model preservation
* fix(smart-routing): preserve inherited fork models
* fix(smart-routing): preserve parent request selection in forks
* fix(smart-routing): preserve selection across context boundaries
* fix(smart-routing): keep inherited runtime and request selections paired
* fix: preserve fork selections across tool and provider changes
* fix: preserve model selections across asynchronous context lifetimes
* fix: preserve child model policy and recovery origin
* fix: validate skill admission before effects and retain recovery ownership
* fix: establish ambient fork models against the current provider
* fix: preserve assigned child models in plan mode
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jatmn <the@jat.mn>
* feat: add OrcaRouter gateway descriptor
Add first-class support for OrcaRouter, an OpenAI-compatible model routing
gateway, as a generated gateway descriptor with hybrid catalog discovery
and curated flagship models (openai/gpt-5.5, anthropic/claude-sonnet-4.6,
google/gemini-3.5-flash) plus its orcarouter/auto named router. Registers
ORCAROUTER_API_KEY in the env-file allow-list, surfaces the provider in the
web provider list and README, and updates the compatibility/registry tests
for the new preset.
Co-Authored-By: Claude <noreply@anthropic.com>
* test: include OrcaRouter in provider picker navigation
* fix(orcarouter): preserve profile credentials and model limits
* fix(orcarouter): keep managed keys scoped to their profiles
* fix(orcarouter): preserve credential ownership across process boundaries
* fix: preserve Orca credential ownership across child startup
* fix(orcarouter): preserve credential provenance and discovery auth
* fix(orcarouter): preserve saved credential origin across launch paths
* test(orcarouter): restore credential origin in shim tests
* fix(orcarouter): preserve pane credential provenance
* docs(orcarouter): clarify dedicated-key migration
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: jatmn <the@jat.mn>
* fix(sdk): wire options.hooks callbacks into the in-process hook executor
QueryOptions.hooks was declared but never consumed by the in-process
query() path — SDK consumers passing PreToolUse/PostToolUse/Stop
callbacks got zero invocations, silently.
The kernel hook executor already supports callback-form hooks:
utils/hooks.ts merges getRegisteredHooks() into every hook event, and
bootstrap/state.ts exposes registerHookCallbacks() for exactly this
purpose (the CLI path wires the same mechanism via the control
protocol in print.ts).
This registers options.hooks (converted to HookCallbackMatcher form)
during query() setup so in-process SDK consumers get the same hook
semantics as CLI users:
- PreToolUse deny/ask decisions now gate tool execution
- Stop hooks fire when the agent attempts to finish
- All HookEvents flow through the standard executor
Verified against dist/sdk.mjs with GLM gateway end-to-end:
- PreToolUse callback fires and its deny decision blocks the tool call
- Stop callback fires on result
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(sdk): isolate query hook callbacks and clean up their lifecycle
Keep options.hooks on each query's existing async context instead of
registering callbacks globally. Release callbacks on close and terminal
iteration, and cover matching, denial output, concurrent queries, and
cleanup without affecting process-level hooks.
* fix(sdk): validate hook matchers and preserve callback arguments
Skip malformed matcher values before registration without broadening them
to wildcards. Preserve omitted, undefined, and empty matchers, and forward
the executor's full callback tuple including cancellation and state access.
Cover both boundaries through the SDK query lifecycle and real hook
executor, including live abort propagation and valid optional matchers.
* test(sdk): isolate query hook fixtures from persisted settings
---------
Co-authored-by: renjie.yin <renjie.yin@ronds.com.cn>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: jatmn <the@jat.mn>