Repository navigation
Conversation
With a streamed prompt and hooks, a can_use_tool callback or SDK MCP servers configured, query() could close stdin right after writing a message, before that message's turn had produced a single frame. Control requests from that turn then failed CLI-side with "Stream closed". Query#end_run cleared the between-turns ceiling first and marked the run ended second. Clearing the ceiling stops the sleeper task, and stopping a task yields to whatever else is ready. A stream_input task that was ready at that moment wrote its next message, found the run still open (so reopen_run did nothing), and started waiting on the run that was about to end; end! then released it and stdin closed. It needed the CLI's "idle" and the next message to become ready in the same reactor pass, with the ceiling armed. The two statements are swapped: the run is marked ended before the first suspension point, so a message written in that gap gets a run of its own. Nothing else relied on the old order. end_run_at_ceiling detaches the sleeper before it calls end_run, wait_for_result_and_end_input clears the ceiling itself, and arm_run_end_ceiling and reopen_run both read the ended flag, which is now already set when another task can run. The new spec forces the interleaving with two enqueues and no clock. Exploration finding: F-P3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ
A hook, a can_use_tool callback or an SDK MCP tool / resource / prompt handler that raised NotImplementedError, LoadError (both ScriptError), SystemStackError or SecurityError was not rescued anywhere: every clause on the way out rescued StandardError only. No control response was written, so the CLI kept waiting for that tool call, and the handler task ended with an exception Async treats as fatal, which stopped the reactor and every other task and session on it. query() raised the raw exception. The same happened when the callback_wrapper raised one. FiberBoundary::CALLBACK_FAILURES now lists what a failing callback raises (StandardError, ScriptError, SystemStackError, SecurityError) and is rescued where a callback's failure becomes the CLI's answer: - SdkMcpServer's two tool rescues, so the model gets the same in-band isError result an ordinary exception produces. - The last rescue of Query#handle_control_request, after the Async::Stop and SystemExit / SignalException clauses, which are unchanged. Hooks and can_use_tool get the error control response; a resource or prompt handler gets the JSON-RPC -32603 error it gets for a RuntimeError. That shape is built by the helper respond_to_process_exit already used, now shared, because such an exception has passed the rescue in handle_sdk_mcp_request that shapes ordinary failures. An explicit list rather than `rescue Exception`: cancellation and process exits keep propagating, and NoMemoryError and bare Exception subclasses stay fatal. Users can notice: the session and the reactor survive, and the model sees the failure text. SdkMcpServer#call_tool called directly returns an isError result for these exceptions instead of raising them. Exploration finding: D-P1 (= A-P2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda
get_session_messages and get_subagent_messages returned tool_use blocks without their tool_result whenever one assistant turn made several tool calls. The CLI writes one assistant entry per tool_use and parents each tool_result on the entry holding its own tool_use, so only the result the conversation continued from is an ancestor of the leaf; the others are siblings of the next tool_use entry, and the readers follow a single parentUuid path. A caller rendering the history saw calls with no result; a caller replaying it to the API sent tool_use without tool_result. After the leaf-to-root walk, the readers now put those results back: for each assistant entry on the chain, its user children that are off the chain and carry a tool_result for a tool_use on the chain, in file order, before the next user entry of the chain. The tool_use_id match keeps other user siblings out (the prompt of a branch abandoned by a rewind is one). Subagent transcripts get the same pass without the sidechain/meta/team rejection, since every entry there is a sidechain entry. Disk and SessionStore readers share the code. Callers see more messages for affected sessions, so offset/limit pages over them shift. Exploration finding: U-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
list_sessions(directory:) left out the sessions recorded for the directory itself when the directory was a subdirectory of a repository with more than one git worktree (a monorepo package, with agent worktrees around). `git worktree list` reports worktree roots; with several of them the listing read only the project dirs of those roots, so the caller's directory was never read. The result held the sessions of every worktree root and none of the ones asked for, without an error. get_session_info and get_session_messages found the same sessions, and so did include_worktrees: false. The multi-worktree branch now reads the caller's directory first, then each worktree, reading a project dir once (as the Python SDK does). Noticeable beyond the missing sessions: when the same session sits in several of those project dirs with equal mtime and size, the copy kept is now the one in the directory that was asked about, then the worktrees in `git worktree list` order. It used to be the main worktree's copy whichever worktree was queried. docs/sessions.md says so. Adds the first examples that run the porcelain parser against a real `git init` + `git worktree add`. Exploration finding: B-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
When a hook or can_use_tool callback raised an exception whose message held bytes that are not valid UTF-8 (a multibyte character cut by byteslice, binary output of a subprocess or an HTTP body, a BINARY-tagged string), the error response was never sent: send_control_error ran JSON.generate inside the rescue clause that was answering the request, and the JSON::GeneratorError it raised had no rescue left. The CLI kept waiting for that request. On the process-exit path (#119) the same GeneratorError replaced the SystemExit that has to end the process: with :thread scheduling the process died of the wrong exception, with :inline it kept running after `exit`. The error text now goes through a private Query#wire_text before it is serialized: valid UTF-8 is untouched; UTF-8, BINARY and US-ASCII strings are read as UTF-8 with U+FFFD in place of the bad bytes; any other encoding is transcoded, so valid ISO-8859-1 or Shift_JIS text arrives intact. Both writers use it: send_control_error and the shared answer behind respond_to_process_exit. As a second line of defense, a response JSON.generate still rejects is replaced by a fixed ASCII text (or, for an SDK MCP request, by the error response), so an answer is written whatever the message holds and the original process-exit exception is the one re-raised. Users can notice: such a request now gets its error response, with U+FFFD where the message had invalid bytes. Exploration finding: A-P5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda
git prints a worktree path as the filesystem stores it. On a filesystem that decomposes names (HFS+) an accented character arrives as base letter + combining mark, and that form sanitizes to a different project directory name than the NFC form the CLI (and canonicalize_path) uses. The sessions of such a worktree were missing from list_sessions and not found by the other directory-scoped readers and mutations when they were looked up through another worktree of the repository. detect_worktrees now normalizes each path it parses, as the Python SDK does. The regression example runs the reader against a `git` on PATH that prints a decomposed path. Exploration finding: B-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
Two fibers of one Async reactor calling CLIInstaller.install for the same directory never returned. The first took the lock and parked in a scheduler-aware wait inside the critical section (resolving the dist-tag, fetching the manifest, downloading); the second then called flock(LOCK_EX), which has no Fiber-scheduler hook and blocked the reactor thread, so the lock holder was never resumed. With the default version: 'stable' this happened even when nothing needed installing, because the tag is resolved inside the lock. Threads and processes were not affected. with_install_lock now polls: sleep(LOCK_POLL_SECONDS) until flock(LOCK_EX | LOCK_NB). Kernel#sleep yields to a Fiber scheduler and is an ordinary sleep on a plain thread. Mutual exclusion is unchanged, and so are the publish order, the in-lock dist-tag resolve and the in-lock sweep that formal/tla/CLIInstaller.tla checks (the model never assumed a fair or FIFO lock). The regression spec runs the two fibers in a child process, because a regression blocks the calling thread instead of failing. The existing thread-contention example recognized a waiter by its innermost frame being File#flock; a waiter is now asleep between attempts, so the helper looks for the poll loop instead. User-visible: a waiting installer wakes up to 50 ms after the lock is released instead of immediately. Exploration finding: D-P5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
The CLI sends one assistant frame per content block, and every frame of an API response repeats that response's message id and usage snapshot. OTelObserver put the snapshot on every claude_agent.generation span, so a response with a thinking block and a tool call counted its input, cache-read and cache-creation tokens twice (N times for N blocks). On a recorded real turn the ResultMessage reported 18 / 45,028 / 4,928 input / cache-read / cache-creation tokens and the generation spans summed to 36 / 90,056 / 9,856. The gen_ai.usage.* token attributes now go on the first generation span of each message id; the other spans of that response carry none. The ids are remembered per trace and cleared when it ends (result, superseding init, close). A message without an id keeps its usage, and a frame without usage does not use up its id. Span count, names and parentage are unchanged, and the session span was already correct. Backends that sum gen_ai.usage.* over generation spans see the totals drop by the duplication factor. gen_ai.usage.output_tokens on a generation span is still the snapshot taken when the response started, not its final output count; docs/observability.md now says so and points at the session span for totals. The spec replays a sanitized recording of a real CLI 2.1.286 turn (spec/fixtures/cli_frames/), because the hand-built messages in the existing examples hold a whole response in one frame with no message id, a shape the CLI does not send. Exploration finding: H-P3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS
Store-backed resume runs `security find-generic-password` whenever the caller has no CLAUDE_CONFIG_DIR and no env auth. session_resume_spec.rb stubbed that bridge in five places only, so on a default macOS setup eight ordinary examples reached it through the public resume flow: 11 real `security` invocations per run, a copy of the developer's credentials (minus the refresh token) written to a temp dir, and results that depended on the local Keychain. With CLAUDE_CONFIG_DIR set, the same run made none and the bridge had no coverage at all. session_resume_spec.rb now stubs read_keychain_credentials for every example that does not opt in with `keychain: true`. session_resume_keychain_spec.rb tests the bridge itself, reached through materialize_resume_session with capture_with_timeout replaced by a recorder: the argv and service name, a hit (redacted, 0600), the precedence over ~/.claude/.credentials.json, a missing or empty entry, a timeout, env auth, and a non-macOS host. The host OS and the environment variables the bridge reads are pinned, so the examples also run on Linux CI and in a shell that exports ANTHROPIC_API_KEY. capture_with_timeout gets examples against a real `ruby -e` child: output, output larger than a pipe buffer, a non-zero exit, a child killed and reaped at the deadline, and a command that cannot be spawned. Specs only; no library change. Exploration finding: F-C1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw
A control method (Client#interrupt, #set_model, ...) called from a fiber
on a reactor other than the session's own — `Sync { client.interrupt }`
on a request thread while the session lives on a background reactor
thread, or a Sync block inside a :thread-mode callback — waited on an
Async::Condition, because send_control_request chose the waiter by "the
caller has an Async task". The session's read loop signals that waiter
from its own thread:
- async 2.10-2.28: Condition#signal transfers to the waiting fiber, which
raises "FiberError: fiber called across threads" inside the read loop.
The read loop treats it as a fatal stream error, so the whole session
ends and the caller waits out the control-request timeout (1200 s by
default). Reproduced against the real CLI.
- async >= 2.29: the signal is thread-safe but edge-triggered. A response
routed between the caller's slot check and its park is lost, and the
caller waits out the same timeout with its answer already stored.
The waiter is now chosen by ownership, with the predicate Query#close
already uses: an Async::Condition only for a fiber of the owning reactor
(Fiber.scheduler.equal?(@owning_scheduler)), the level-triggered
ThreadWaiter for every other caller. The deadline mechanism is still
chosen by "has a task", so a foreign fiber parks in Thread::Queue#pop
under the cooperative timeout. A reactor caller on a Query that was never
started has no owning reactor and gets the ThreadWaiter as well.
formal/tla/ControlProtocol.tla and its README now describe sender 2 as
any caller not on the owning reactor. Comments only: the model is
unchanged and run.sh still meets all 16 expectations.
Exploration finding: A-P3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ
get_session_messages and get_subagent_messages raised Encoding::CompatibilityError (not a ClaudeSDKError) in two situations: - under a non-UTF-8 locale (LANG=C / LC_ALL=C: minimal Docker images, cron, systemd), for any transcript with one non-ASCII character, because the file was read in the locale's encoding; - in any locale, for a transcript whose last line stops inside a multibyte character (the CLI was killed mid-write) or that holds a raw non-UTF-8 byte in a tool result. This one was permanent: a later rename_session appends after the broken line and the read kept raising. list_sessions, get_session_info, fork_session and import_session_to_store already read the same files without failing. The reader now opens the transcript in binary mode, tags each line as UTF-8 and scrubs it when it is not valid, the strategy SessionMutations.parse_fork_transcript already uses. A torn line still fails to parse and is skipped; a complete line with a stray byte keeps its entry, with the byte replaced by U+FFFD (noticeable: that message used to make the whole read fail). Exploration finding: B-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
Type#inspect filtered only `env` and MCP `headers`. Logging an options object or a server config still printed credentials held elsewhere: `settings` (a JSON String, or a Hash with `env` / `apiKeyHelper`), the values of `extra_args`, McpStdioServerConfig#args (`--api-key ...`), and the `url` of an HTTP/SSE server (a token in the userinfo, path or query). Raw Hash server configs inside `mcp_servers` showed their url in full, and their `env` / `headers` stayed out of the output only because they sat below the nesting limit. Now: - ClaudeAgentOptions filters `settings` and `extra_args` like `env`: a Hash keeps its keys, any other value (a JSON String, a settings path) prints as "[FILTERED]". - McpStdioServerConfig filters `args`. - An MCP url prints as scheme, host and port only, in the typed configs and in raw Hash configs: "https://mcp.example.com/[FILTERED]". A value that is not an http(s)/ws(s) URL with a plain host is replaced outright. - A raw Hash config in `mcp_servers` keeps its keys and shows `type`, `command`, `name`, `instance` and the redacted `url`; every other value is filtered, whatever its depth. Display only: the objects are not modified, and #to_h and the command line are byte-identical to before (pinned by the new spec). Exploration finding: C-P10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP
A boot-time CLIInstaller.install_pinned raised CLIInstallError (EACCES) in a directory the process cannot write, although the pinned binary was installed and intact: an image built as root and run as another user, a read-only root filesystem. The idempotent shortcut (re-hash the binary, compare with VERSION, return) sits inside the install lock, and the lock file is opened read-write, and created, before it is ever consulted. Lock-free discovery (installed_path, find_cli) kept working, so only callers that run install at boot were hit, which is what the docs recommend. install now rescues EACCES, EROFS and EPERM around opening the lock file and nothing else. When the request is a concrete version that is installed and re-hashes to its recorded checksum for this platform, it returns the binary; otherwise it raises exactly as before. A dist-tag always raises there: it has to be resolved and may have to be published, which needs the lock. A permission error raised inside the critical section still fails the install. Nothing is written and no lock is taken on that path, so it is one more lock-free reader of the kind ReadersSeeVerifiedBinary covers; the publish order, the in-lock resolve and the in-lock sweep are untouched, and the TLA model's expectations do not change (formal/tla/README.md says so). with_install_lock now takes the open lock file instead of the directory; both are private. Exploration finding: D-P7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
import_session_to_store skipped a truncated line with a warning, but a complete JSON line holding a raw non-UTF-8 byte (binary tool output) parsed — Ruby's JSON parser accepts such a string — and the resulting entry made any adapter that serializes its entries raise JSON::GeneratorError from #append. The import stopped there, leaving the batches before it in the store. The import now reads the transcript as bytes, tags each line as UTF-8 and scrubs it when it is not valid, like the message readers and fork_session. The entry is stored with U+FFFD in place of the stray byte. Exploration finding: B-P16 (item 3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
The suite failed for some seeds (`rspec --seed 81`, `--seed 124`): six examples in query_run_end_spec.rb call SubprocessCLITransport#connect with Open3.popen3 stubbed and never close, so each left an instance_double(Process::Waiter) in the process-wide at-exit registry. When that group ran before the transport spec, the registry examples there counted seven entries instead of one, or sent `alive?` to an expired double. It looked like a transport regression and was neither. The group now clears the registry after each example. spec_helper.rb adds the same check for the whole suite: an example that leaves an entry behind fails, naming what it left, and the registry is emptied for the next example. Both go through the registry's own mutex, because a fallback-termination worker from an earlier example may still be deregistering on its thread. Specs only; no library change. Exploration finding: F-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN
`msg[:session_id=]`, `msg['sessionId=']` and `options[:env=]` raised
`ArgumentError: wrong number of arguments (given 0, expected 1)`: the
attribute check strips a trailing `=`, so #[] took the writer for an
attribute and called it without an argument, after storing the name in
the per-class reader cache. docs/types.md promises nil for a name that is
not an attribute. #[] now returns nil for any name ending in `=`, before
the cache is consulted (the camelCase writers resolved by method_missing
live in the same cache) and without caching it.
`ClaudeAgentOptions.new('[]' => 1)` and `options['[]'] = 1` raised
`wrong number of arguments (given 1, expected 2)`: the option check was a
bare `respond_to?("#{name}=")`, which #[]= satisfies. An option name is
now a declared attribute or a setter user code defined on a subclass, a
mixin or the object (those still work), so '[]' gets the usual
"unknown ClaudeAgentOptions option" error, spelled as written with or
without configured defaults.
Noticeable: the keys '=', '!' and '==' used to call #==, #!= and #===
with the value and were dropped silently; they now raise the same
unknown-option ArgumentError.
Exploration finding: C-P12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP
OTelObserver#on_error returned when no root span existed, and the root span is only created by the InitMessage. A CLI that could not be found or started, or an initialize that failed or timed out, therefore raised to the caller and exported no span at all. on_error now gives an error that arrives before the session's first InitMessage a claude_agent.session span of its own: the observer's default attributes, the prompt if one had been sent, the exception event and error status. No model or session.id, since the CLI never reported them. The span is finished in on_error and never becomes the root span. query() does call on_close afterwards, but a Client#connect that fails before the handshake never does, and an unfinished span is never exported. With no root span set, a later on_close has nothing to finish a second time, and the buffers are reset so the failed session's prompt cannot label the next trace of a reused observer. Only errors before the first InitMessage get this span. The CLI exits non-zero after an error result, so query() raises a ResultError once the trace has already ended and reported the failure; a second session span for it would be a duplicate. Such an error stays unrecorded, as before, until on_close starts the next session. Users who count claude_agent.session spans will now see failed starts among them. Exploration finding: H-P5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS
CLIInstaller downloaded without the proxy in an environment that exports only HTTPS_PROXY / https_proxy, which is what curl, RubyGems and the CLI itself read for an https URL. Net::HTTP.start without proxy arguments looks the proxy up as if for an http:// URL: it reads http_proxy even for a TLS connection. Where direct egress is blocked the install failed after the 10 s open timeout, with a message that does not mention the proxy. Http.with_response now resolves the proxy with URI#find_proxy on the https URL, for every redirect hop, and passes its address, port, user and password to Net::HTTP.start explicitly; credentials are percent-decoded the way Net::HTTP decodes the ones it finds itself. no_proxy / NO_PROXY behave as find_proxy defines them. Nothing else changes route. With no usable HTTPS proxy, no arguments are passed and Net::HTTP's own lookup stays in charge, so an environment that only sets http_proxy keeps working. A value Net::HTTP cannot use as an HTTP proxy (no scheme, socks5://, https://, not a URL) was ignored before and still is, instead of turning a download that works directly into a failure. ALL_PROXY is not consulted, so this is not curl parity; docs/cli-installer.md gains a "Proxies and custom CAs" section that says so and also covers NO_PROXY and SSL_CERT_FILE. The new spec file stubs only Net::HTTP.start and answers with real Net::HTTPResponse objects parsed from raw bytes. User-visible: a host that sets https_proxy to an http:// proxy now downloads the CLI through it. Exploration finding: D-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
After the CLI went away while a Client was connected (it exited, crashed or was killed), the first receive_response / receive_messages raised the stream error or returned the last turn, the next one returned zero messages, and every call after that parked forever: no error, no timeout, a live reactor task waiting on a queue nothing writes to. A worker that retries its receive after an error, or loops on receive_messages for background-task notifications, hung for the life of the process. The read loop enqueues one end marker when it finishes and Query#receive_messages consumed it. It now puts the marker back before it stops, so every later call ends at once, as iteration over a closed stream does in the Python SDK. A stream error is still raised once, by the first call that reaches it; later calls return without messages. The marker goes back only when the caller is inside an Async task: async before 2.29 raises for an Async::Queue#enqueue outside one, which would have turned a receive called after the reactor has ended (it drains what is queued today) into a RuntimeError on those versions. That call shape keeps its previous behavior on every async version. Exploration finding: A-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ
On macOS the CLI keeps OAuth credentials in the Keychain. With a custom CLAUDE_CONFIG_DIR it stores them under "Claude Code-credentials-<first 8 hex of SHA-256(config dir)>" and writes no .credentials.json. Store-backed resume consulted the Keychain only for the default config dir, and only under the fixed service name, so with a custom dir the materialized config dir got no credentials: the resumed CLI answered "Not logged in · Please run /login", and the failed attempt was mirrored into the store as a junk turn. copy_auth_files now reads the Keychain for a custom config dir too, deriving the service name from the directory the way the CLI does (the environment value as given, NFC-normalized). It does so only when that directory has no .credentials.json: which source the CLI prefers when both exist is not established, so a file that is there keeps winning, as before. Unchanged: ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN skip the bridge, the default config dir behaves as before, a non-macOS host never runs `security`, and the seeded copy has no refresh token and mode 0600. A user can notice: a store-backed resume on macOS with a custom config dir and a Keychain login now authenticates. It runs one `security find-generic-password`, which can raise a Keychain access prompt, as the default-dir lookup already could. The specs go through materialize_resume_session with capture_with_timeout stubbed; the expected service names are digests computed outside Ruby. They also pin that the output of a failed lookup is never used. A real-CLI integration example mirrors one turn into an InMemorySessionStore, removes the local transcript and resumes from the store. Exploration finding: B-P4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw
docs/observability.md and the OTelObserver class comment promised "standard gen_ai.* semantic conventions" that work with "any OTel backend". The observer emits nine gen_ai.* names, and six of them are deprecated (gen_ai.system), removed (gen_ai.completion), not defined (the two underscore-named cache counts, gen_ai.usage.cost) or defined differently (gen_ai.usage.input_tokens leaves cache tokens out; the convention expects them included) in the OTel GenAI semantic conventions. A backend that reads spans by those conventions sees 18 input tokens for a recorded turn that consumed 49,974. The page now says the attributes follow the Langfuse and OpenInference conventions plus a subset of OTel gen_ai.*, lists every attribute, event and status the observer sets per span (the old table named about half), and has a paragraph on how the gen_ai.* names compare with the conventions and where to read the inclusive input count (llm.token_count.prompt on the session span). The class comment points there. Documentation only: no attribute is added, renamed or removed. Exploration finding: H-P10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS
`ToolUseBlock.new(input: BasicObject.new).inspect` raised `NoMethodError: undefined method 'nil?'`: the nil check that skips unset attributes sent `nil?` to the raw value before the guarded rendering was reached. A String subclass whose #length raises went through the truncation unguarded and raised too, as did a Hash or Array subclass with a raising #size / #first / #empty?, a BasicObject Hash key, and a BasicObject in a filtered attribute (`env`, `settings`, `url`). #inspect runs inside loggers, `puts` and exception messages, and its own comment says printing must never raise. Unset attributes are now detected with `nil.equal?(value)`, and the bounded rendering of every value (and of the inspected object itself) rescues StandardError: the value shows as `#<ClassName>`, truncated like any other text, or `#<?>` when it cannot be asked for its class, and the rest of the object still prints. A filtered attribute that fails while it is filtered prints as "[FILTERED]", never as itself. Interrupt, SystemExit and other non-StandardError exceptions pass through. Exploration finding: C-P14 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP
Nothing executed Http.with_response and Http.follow_redirect: the installer specs replace Http.fetch_text / Http.download_to wholesale. Deleting the "Refusing to fetch non-HTTPS URL" check, or letting the redirect budget never decrease, left the whole suite green. The Http spec file now covers them with canned responses behind a stubbed Net::HTTP.start (stdlib only; the responses are real Net::HTTPResponse objects parsed from raw bytes): - an http:// URL is refused before any connection is opened; - a redirect from https to http is refused, not followed; - five redirects are followed, the sixth raises "Too many redirects" after exactly six requests; - a relative Location is resolved against the URL that sent it, and a redirect without Location raises; - a 404 raises with its status line, and an error page never creates the target file; a redirect body is never streamed into it; - a SocketError is wrapped in CLIInstallError with cause, while a CLIInstallError from the body handler passes through unwrapped; - every hop is opened with use_ssl: true and the timeouts, and with no option that weakens peer verification. That option, not the scheme check, is what keeps the transfer encrypted: with the check removed, Net::HTTP still speaks TLS to an http:// URL's host. No production change. Each of these mutants now fails the file: the scheme check deleted, the budget not decremented, use_ssl dropped, the status tested after the body is handed over, the missing-Location check deleted, socket errors left unwrapped. Exploration finding: F-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
When the transcript mirror dropped batches, teardown keeps the materialized config dir (it holds the only copy of the dropped turns) and removes the credential copies from it. That scrub was a deny-list of four names: .credentials.json, .claude.json, settings.json and cowork_settings.json. The CLI also writes into the directory. At startup it saves the seeded .claude.json, which can hold MCP header secrets, as backups/.claude.json.backup.<ts>, and that copy stayed behind next to cache/ and sessions/. preserve_transcripts is now an allow-list: projects/ stays, every other entry of the temp dir is deleted, whatever its name. A symlink is removed, not followed. A user can notice: a preserved directory contains nothing but projects/. The warning that names it is unchanged. Exploration finding: B-P8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw
docs/mcp-servers.md said that SdkMcpServer#call_tool, called without a session, "simply lets such exceptions propagate". "Such" meant exit and signal exceptions, but the sentence now sits two lines below the one about NotImplementedError, LoadError, SystemStackError and SecurityError, which a direct call_tool reports as an isError result. Name the exceptions instead. Exploration finding: D-P1 (= A-P2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda
The 37 keyword constructors in sig/ (ClaudeAgentOptions, the option values, hook outputs, MCP server configs, permission types) are signed `(?name: T, ...) -> void | (Hash[Symbol | String, untyped]? attributes) -> void`. `Klass.new(typo: 1)` is itself a Symbol-keyed Hash, so it always matches the second overload: Steep reports neither a misspelled keyword nor a wrong value type, and the runtime checker behind `rake rbs:test` never reports a constructor mismatch (it only checks the attribute setters the constructor calls). Say so where a signature author and a typed consumer will read it: the comment above the value types in option_values.rbs and CONTRIBUTING's "RBS signatures" section. The keyword overload documents the accepted keys; the enforcement is the runtime ArgumentError for an unknown key. No signature changes. Exploration finding: C-P8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP
get_session_messages (disk and session_store:) returned [] for a whole conversation whose last entry was a meta injection nobody answered: the body of a slash command or skill, a stop-hook message, a system reminder, written just before the user closed the session. The reader only accepted a leaf that was not sidechain, team or meta and gave up when there was none. The session was still listed, with its summary. Without a main leaf the reader now falls back to the other leaf candidates, as the Python SDK does; the visibility filter drops the flagged entries of the chain as before. Among those candidates a leaf whose path to the root passes a visible message is preferred, then file position: taking simply the latest one picks a later sidechain or teammate leaf when there is one and still reads the conversation as empty. Transcripts that are sidechain or teammate entries throughout keep returning []. Exploration finding: B-P3 (= U-P7) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
With observers configured, a deadline around Client#receive_response, #receive_messages, #query or #disconnect (task.with_timeout or Timeout.timeout inside a reactor) was lost when it expired while an observer callback was still running: nothing was raised, and the receive loop went on to hand the message, and the rest of the turn, to the caller's block. A slow observer was enough. notify_observers wrapped the whole FiberBoundary hop in `rescue StandardError, ScriptError`. The calling fiber waits in that hop, and a deadline is an exception raised into the waiting fiber, so it was caught as if the observer had failed. The observer's failure is now contained inside the hop, on the execution context the observer runs on, and whatever is raised into the waiting fiber propagates. The callback_wrapper is composed inside the same rescued body instead of being handed to FiberBoundary.invoke, so a wrapper that raises around an observer is still contained and messages are still delivered, as before. ScriptError stays in the list. Not covered: with callback_scheduling: :inline the observer runs on the calling fiber, so a deadline that lands while it is suspended is raised inside the observer's own frames and is still contained with the observer's own failures. Exploration finding: A-P7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ
For a project path containing a character outside the BMP (an emoji, a CJK Extension B ideograph), the SDK derived a different project directory name than the CLI. The CLI sanitizes with JavaScript's replace(/[^a-zA-Z0-9]/g, "-") without the `u` flag, i.e. per UTF-16 code unit, so such a character becomes two hyphens; the SDK replaced per code point and wrote one. For those paths every directory-scoped session API silently came back empty (list_sessions, get_session_info, get_session_messages, the subagent readers, rename/tag/delete/fork, import_session_to_store), and project_key_for_directory returned a key that did not match the one the transcript mirror writes under, so store-backed reads and resume could not find the mirrored session. sanitize_path now writes two hyphens for a code point above U+FFFF. Checked against the directory names CLI 2.1.286 creates (emoji, CJK Extension B, and a path over 200 characters). project_key_for_directory changes for affected paths only. Store entries written under the old key by import_session_to_store or by the store mutations are no longer addressed by it; they were already unreachable from the mirror's key. Exploration finding: U-P5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
A shorthand tool schema such as { order_id: Integer, tags: Array } told
the model that tags is a string: Array, Hash, :array and :object all
fell into the default branch of ruby_type_to_json_schema. In a real
session the model then sent "fragile,gift" and the handler received a
String where it had declared an Array; a model that did send an array
had the call rejected by argument validation ("value at `/tags` is not a
string").
Array / :array now map to { type: 'array' } and Hash / :object to
{ type: 'object' }, the same kind of fix 8e3419c made for Integer, Float
and the booleans. tools/list and the tools/call validation both derive
from normalize_tool_schema, so they agree: an array or object argument
is accepted and reaches the handler, anything else is rejected in-band.
Checked on mcp 0.22.0 and 1.6.1.
Every other shorthand value keeps its present meaning: an unrecognized
Symbol or class, an Array value such as [String] and a nested schema
fragment are still advertised as a string, with no warning and no error
(that policy is a separate decision). { type: :object } is still read as
a prebuilt accept-any-object schema, as it always was; a parameter
literally named type is declared with { type: Hash }.
docs/mcp-servers.md had no description of the shorthand at all; it gains
a short table of the accepted types.
User-visible: a tool that declared Array or Hash and coped with the
String it used to receive now receives an Array or a Hash.
Exploration finding: D-P8 (= U-P18)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
Cut short by something that is not a StandardError (a cancellation, a
signal), preserve_transcripts warned where the session transcript is and
nothing else. Since #cleanup leaves the directory alone once
preserve_transcripts was called, that warning is all the user gets: it
has to say that copies which were not removed yet — credentials among
them — may be left, and which directory to remove once the transcript
is imported.
It now does, and names the directory: the private one the temp dir was
moved into when the move had happened (the trash is in there too), the
temp dir itself when it had not. Never the temp dir's parent, which
would be the system's temp directory. Whether the move happened is read
from config_dir, which changes exactly then.
... Scrubbing was interrupted; the session transcript is under
<dir>/projects. Copies of your credentials and settings that were
not removed yet may be left under <to remove> — import the
transcript into your session store, then remove <to remove>.
Two examples replace the one that only checked the transcript's
location: interrupted after the move (names the private directory, not
the directory in it) and before the move (names the temp dir, not its
parent).
Exploration finding: B-P8 (round 3b follow-up)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw
Two forms a Ruby caller writes naturally had no branch in the command
builder, and each was dropped or broke without a useful word:
- tools: 'Read,Grep', the syntax of the CLI's own --tools flag. The
option was matched against Array, ToolsPreset and Hash only, so no
flag was sent and the session got EVERY built-in tool instead of the
two it was restricted to.
- settings: Pathname (Rails.root.join('config/claude.json')) is neither
a String nor a Hash: no --settings was sent, and the file's permission
rules were not applied.
- SystemPromptFile.new(path: Pathname), a Hash system prompt with a
Pathname path, and a plugin path given as a Pathname put the Pathname
itself on the command line. The spawn rejects it, so connect raised
CLIConnectionError "no implicit conversion of Pathname into String".
tools: now takes a String and passes it to --tools as written. A
Pathname (anything that answers #to_path) is converted where the builder
puts a path on the command line: the system prompt file, typed or Hash,
and each plugin directory, typed or Hash. settings: as a Pathname is a
settings file: with no sandbox: option the path is passed through, with
one the file is read and merged, both exactly as for a String path. It
is never tried as inline JSON, which a String is first. The typed values
keep the Pathname they were given; only the command line gets a String.
Values of any other class keep doing what they did (tools: 42 still
sends no flag): no new error.
The signatures widen accordingly: tools: gains String, settings: gains
Pathname, and SystemPromptFile#path and SdkPluginConfig#path gain
Pathname (constructor, accessor and #to_h). docs/options.md follows for
those rows.
User-visible: a tools: String that was ignored now restricts the session
to the tools it names.
Exploration finding: D-P3 / C-P4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
A sandbox: Hash may hold a typed section:
sandbox: { enabled: true,
network: SandboxNetworkConfig.new(denied_domains: [...]) }
JSON.generate has no way to write a typed value, so the section went out
as its #inspect text, "#<ClaudeAgentSDK::SandboxNetworkConfig ...>". The
CLI wants an object there ("sandbox.network: Expected object, but
received string") and discards the whole --settings value over it: the
session ran with no sandbox at all, and without the permissions passed
in the same settings.
The network and filesystem of a Hash sandbox: now go through the same
two functions a typed SandboxSettings uses for its own sections, so a
typed config is written by its #to_h and a Hash has its keys renamed, as
before. Any other value is still sent as written.
Exploration finding: C-P2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
A plugin given as a Hash carries a type, 'local' (or 'plugin', its older
name). The command builder compared it with those Strings, so the Ruby
spelling
plugins: [{ type: :local, path: '/srv/app/plugins/review' }]
raised ArgumentError "Unsupported plugin type: :local" when the command
line was built. The type is now read the way system_prompt, tools and
output_format read theirs, as a String whatever it was written as.
A type that is not a plugin type is refused as before, and the message
still shows it as the caller wrote it (:remote, "remote", nil).
Exploration finding: D-P2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
docs/configuration.md, in the sections that show these options: - Sandbox Settings: sandbox: and its network / filesystem take a Hash, which may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; any other key is sent as written; a snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts, and is otherwise sent as written; a field holding nil is left out. - The same section now says what makes that last rule necessary, and is true of every form, the typed classes included: when one sandbox value fails the CLI's settings schema, the CLI (2.1.287) discards the whole --settings value, sandbox and permissions rules alike, and reports it nowhere. connect succeeds, nothing appears on stderr, and the session runs unsandboxed. - Structured Output, System Prompt Snapshot and Tools Configuration: next to each Hash example, `type` may be a String or a Symbol. Tools Configuration also shows the String form of tools: and the preset as a Hash. The signature comments of SandboxSettings and ClaudeAgentOptions#sandbox and the Sandbox section of the two skill trees (kept byte-identical) say the same in one paragraph. No type changes here; the Pathname and String widenings and their docs/options.md rows went in with the code. Docs only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
The [Unreleased] entries of PRs #148-#161, collected here so the fix PRs do not conflict with each other in CHANGELOG.md. Fixes that matter for safety or permissions come first under Fixed; the three "set back to nil" entries are one, and three page corrections made in other PRs are listed with the documentation entry. README: the sentence on what CI runs names Ruby 4.0 and the keyless smoke test the test-suite PR (#153) adds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2
A directory moved into the trash can still receive an entry from something that holds it open. The scrub allowed for that by listing the directory again after taking its entries out — but it stopped at the first empty listing and then called rmdir once. An entry that arrived right after an empty listing was therefore never looked for: rmdir failed with ENOTEMPTY, the entry was reported as not removed and the trash stayed behind. Which case a run hits depends on the order the filesystem lists entries in. The spec that covers a late arrival wrote its file after the first listing of a trash directory: on APFS that was a directory with entries, on ext4 the empty sessions/, so the example passed on macOS and failed on Linux CI. rmdir is now what decides. Each pass lists the directory, takes its entries out and tries rmdir; ENOTEMPTY (EEXIST on some systems) gives it another pass, EMPTYING_PASSES in all, and the last refusal is reported as before. A directory nothing writes into costs one listing less than it did. Specs: the examples that wrap Dir.children now run with the names sorted, in reverse order and shuffled (with the run's seed), so their result no longer depends on the machine. A late arrival is checked after a listing that had entries and after one that was empty; rmdir answering EEXIST is treated the same; a directory that keeps receiving entries is given up on after exactly EMPTYING_PASSES passes and named in the warning. The two swap examples that used to force backups/ to the front now read off the listing what the scrub must have left untouched. Exploration finding: B-P8 (Linux CI failure on PR #159) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw
A snake_case sandbox key is renamed only when the CLI accepts its value
under the wire key. For the two proxy ports the check was "an Integer",
and that is not what the CLI accepts: an Integer too large for it to
read as a number (2**1024 and up, 10**400 for one) reaches it as a
non-finite value, fails its schema ("Expected number, but received
null"), and the CLI discards the whole --settings value over it. So
sandbox: { enabled: true, network: { http_proxy_port: 10**400 } }
ran with no sandbox, and without the permissions passed in the same
settings, where the key was simply ignored before it was renamed. That
is the one thing the rename must never do.
The kind for http_proxy_port and socks_proxy_port is now a port: an
Integer from 0 to 65535 (and is called :port, no longer :integer). A
value outside that range is sent as written and has no effect, as in
1.1.0. The bound is tighter than what the CLI takes, which is any
number it can read; a bound that is too tight leaves a key inert, and
one that is too loose can cost the sandbox. A key written in wire
spelling is still not looked at.
The spec gets the three out-of-range values (10**400, 65536, -1) for
both attributes under Symbol and String keys, the two ends of the range
(0 and 65535) renamed, and httpProxyPort: 10**400 still sent as it is.
The comment on the kinds table also said the CLI discards the settings
"without a word". It lists the error in its get_settings response; the
SDK is what is not told.
Found by review.
Exploration finding: C-P2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
docs/configuration.md ("Sandbox Settings") and the Sandbox paragraph of
the two skill trees said that the CLI, when it discards the --settings
value over one invalid sandbox value, reports this "nowhere". It does
report it: in the errors of its get_settings control response. What is
true, and what the text now says, is that the SDK does not surface that
error: connect succeeds, nothing appears on stderr, and the session runs
unsandboxed.
The same passages said a snake_case key is renamed for "an Integer" as a
proxy port. The check is now for a port number, an Integer from 0 to
65535, and the text says so.
The two skill trees stay byte-identical. Docs only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS
client_receive_after_stream_end_spec.rb defined a helper named receive in its example group. That hides RSpec's own receive matcher for every example in the group, so any before hook that stubs with allow(...).to receive(...) calls the helper instead — as a suite-wide stub added by the store-backed resume PR (#159) does, which made all eight examples fail with "wrong number of arguments" once both PRs are in. The helper is receive_within_deadline now; what it does is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2
The method was one line over Metrics/MethodLength (30) on this branch and carried a disable for it. The sessions PR (#157) shortens the same method by two lines, so once both are merged the disable is redundant and RuboCop fails on Lint/RedundantCopDisableDirective. The two early returns are one condition now, which brings the method under the limit on its own and lets the disable go, with or without #157. Also: the directory identity is read inside the begin block, so a failure while reading it removes the new temp dir like any other failure after mkdtemp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2
Collaborator
Author
|
CI check done: all 14 checks passed on the merged tree (real-CLI job skipped without a key). Closing as planned. |
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Do not merge. A throwaway PR to run CI (Linux, Ruby 3.2–4.0, macOS, Rails, RBS) on all fourteen fix PRs (#148–#161)
plus the CHANGELOG branch merged onto
mainin the planned merge order — two of the PRs had Linux-only failures ontheir own, so the merged tree gets the same check before any of them is merged. It will be closed and its branch
deleted as soon as CI has run.
🤖 Generated with Claude Code
https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2