Repository navigation
Sync with Python SDK 0.2.147 (0.2.137 + 0.2.140 feature releases) - #55
Merged
Merged
Conversation
The CLI announces a mid-session conversation reset (`/clear`, or any other flow that discards the transcript) with a top-level `conversation_reset` frame. The Ruby parser had no branch for it, so it was silently dropped through the forward-compat fallthrough and apps never saw the reset — nor had a signal to snapshot running totals before subsequent ResultMessages zero them. - types.rb: ConversationResetMessage (new_conversation_id, uuid, session_id) - message_parser.rb: parse `conversation_reset`; a missing required field raises MessageParseError rather than yielding a half-built message Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UuAH421iQ9oMARmFsMFfP
Port of Python SDK PR #1197 (b4d65f5). A SessionStore-backed resume runs the CLI under a temporary CLAUDE_CONFIG_DIR seeded from the caller's real one. The seed was `.credentials.json` (refreshToken redacted) and `.claude.json` only — user `settings.json` was left behind, and with it `apiKeyHelper` (a fourth auth mechanism alongside the credentials file, the macOS Keychain and env vars) plus the user's `env`, `hooks` and `permissions`. A host authenticating solely via `apiKeyHelper` failed with "Not logged in" the moment it resumed from the store, with nothing in the error pointing at why. `copy_auth_files` now also seeds `settings.json` and `cowork_settings.json` (the alternate filename the CLI reads in cowork-plugins mode) from the caller's config dir, through `strip_settings_for_resume`, which drops the keys that only misbehave under a redirected config dir: - `enabledPlugins` / `extraKnownMarketplaces` — they reconcile against the always-empty temp plugin cache and would network-install every declared marketplace on each resume; - `env.CLAUDE_CONFIG_DIR` — it would point the subprocess's config reads away from the temp dir. A UTF-8 BOM (PowerShell-written settings) is tolerated; content that doesn't parse as a JSON object is passed through byte-for-byte; nothing is re-serialized when no key was actually stripped; a settings file that JSON.generate refuses to emit (a spec-valid `1e999` parses to Infinity) falls back to the original bytes. Output is written 0600 inside the 0700 temp dir. `read_file_if_present` becomes `read_if_present`: it stats first and requires a regular file (a directory raises, a FIFO would hang the resume forever), returns raw bytes, and logs-and-skips any non-ENOENT failure. `copy_if_present` gains an optional transform and the same skip policy, removing a partially-written destination rather than leaving truncated JSON for the subprocess to misparse — these files are best-effort enrichment and must not abort a resume that would otherwise succeed. `MaterializedResume#preserve_transcripts` scrubs the two new files too: their `env` blocks routinely carry API keys, so the preserve path would otherwise leak secrets it was written to remove.
In streaming-input mode one connection interleaves the turns the application sends with turns the session injects on its own — background task notifications, fired scheduled-task prompts, MCP channel messages, messages relayed from peer sessions. The CLI attributes these with an `origin` object on user messages and forwards the triggering message's origin on each result, so a consumer can tell "this result answers my prompt" from a task-notification follow-up. The Ruby parser dropped it. - types.rb: `origin` on UserMessage and ResultMessage, documented as a pass-through Hash whose keys keep the wire spelling (`:fromSession` stays camelCase), with the known kinds/subkinds listed as documentation rather than validation - message_parser.rb: `parse_origin` passes the CLI's Hash through untouched when it has a String `:kind`, so newer kinds/fields stay visible; anything else reads as absent. The result path merges the validated value over the raw field so a malformed origin is not surfaced verbatim by the base-class attribute assignment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UuAH421iQ9oMARmFsMFfP
Port of Python SDK PR #1205 (sha 90ab957).
The CLI ends a failed run by emitting a `result` frame with is_error=true
(yielded as a ResultMessage) and *then* exiting non-zero on purpose, for
shell-script consumers. The read loop already replaced the bare "exit code 1"
ProcessError with the result's error text, but callers got only a string —
and the text itself was wrong for API failures: those arrive as
subtype "success" + is_error true + empty errors[] with the prose in
`result`, so the old `errors.join('; ') || subtype` chain printed the
self-contradictory "Claude Code returned an error result: success".
- errors.rb: ResultError < ProcessError carrying subtype / errors / result /
api_error_status / terminal_reason / session_id / data, each type-narrowed
(a non-String subtype, a non-Integer api_error_status, ... read back nil).
ResultError.normalize_errors tolerates a bare String, treats anything else
as empty, and drops non-String/blank entries so the structured field and
the exception text can never disagree; ResultError.field reads both key
forms for the same reason (wire payloads are symbolized, a replayed one is
not). Existing `rescue ProcessError` handlers are unaffected.
- Ruby's #cause is only set for an exception raised inside a rescue block,
and the read loop hands this one to the message queue instead of raising
it there — so the replaced ProcessError is exposed explicitly as
#original_error rather than relying on #cause (Python chains __cause__).
- query.rb: @last_error_result_text becomes @last_error_result and keeps the
whole payload; the text is derived at raise time by #error_result_text,
whose order is errors[] -> result -> non-success subtype -> HTTP status ->
"unknown error". Reset logic unchanged. stderr is still carried over
(Ruby has always done so; the original error is reachable anyway).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ojRwGumL91UH627ZPAnjZ
By default only a subagent's tool_use / tool_result blocks enter the message stream — enough for a progress heartbeat. With forward_subagent_text the subagent's text and thinking blocks are forwarded the same way, so consumers can render the full nested transcript. Matches the TypeScript SDK's forwardSubagentText. Sent as an initialize capability rather than a CLI flag, and only when enabled, so an older CLI never sees an unknown key on the common path. Plumbed through both Query construction sites (query() and Client#connect). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UuAH421iQ9oMARmFsMFfP
Port of Python SDK PR #1198's query.py half (sha be2d0df). The read loop's rescue signaled every pending control request with the raw exception *before* computing the ResultError replacement, so requests still in flight when the CLI died got "Command failed with exit code 1" while the message stream got the actionable error. That is exactly the resume-rejection path: a refused resume (a nonexistent session, a failed --resume-drops-turn guard) is reported as an error result on stdout followed by exit 1 *before* the CLI answers the SDK's `initialize`, so the in-flight initialize swallowed the real reason. Fix is the ordering: compute the replacement first, then signal the pending requests with it. The level-trigger invariant (store the result before signaling) and the `||=` first-writer-wins semantics are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ojRwGumL91UH627ZPAnjZ
Port of Python SDK PR #1207 (2bbdce6).
`get_subagent_messages` / `get_subagent_messages_from_store` returned
SessionMessages whose `parent_tool_use_id` was always nil, so callers had
no way to tell which Agent `tool_use` block in the parent session spawned
a given subagent. The information was sitting right next to the
transcript: the `agent-<id>.meta.json` sidecar carries `toolUseId` and
`parentAgentId`, and on the SessionStore path the same data arrives as a
synthetic `{"type": "agent_metadata", ...}` entry that the reader was
dropping on the floor.
SessionMessage gains `parent_agent_id` (the id of the subagent that
spawned this one; only set for nested subagents). Both ids are stamped on
every message of a subagent transcript — they describe the transcript,
not the individual message.
The sidecar naming convention was open-coded in three places (disk read,
session import, resume materialization); it collapses into four shared
helpers on Sessions:
- `agent_metadata_sidecar_path` — the single definition of
`agent-<id>.jsonl` -> `agent-<id>.meta.json`
- `read_agent_metadata_sidecar` — missing / unparseable / non-object
sidecars degrade to "absent"; other IO errors propagate
- `split_agent_metadata` — separates the synthetic entry from transcript
lines, last metadata entry winning (it is rewritten on resume)
- `parent_ids_from_agent_metadata` — String-narrowed id extraction
The disk reader wraps its sidecar read in a `rescue SystemCallError` so a
sidecar that exists but can't be read (a directory, EACCES) degrades to
"no metadata" rather than raising out of a best-effort read. Session
import now also survives a corrupt sidecar instead of aborting midway
with a JSON::ParserError after the transcripts were already appended; it
keeps merging the synthetic `agent_metadata` marker last, so a stray
`type` key in the CLI-owned sidecar can never shadow it.
Half of Python #1198 — `resume_session_at` already exists here; this adds its validation companion. `--resume-drops-turn=<prompt uuid>` declares which user prompt's turn a truncating resume intends to discard; the CLI then validates at load time that every transcript entry past the fork point is attributable to that turn and refuses the resume otherwise. That lets a caller rewind to "before my last prompt" without silently dropping a queued message or task notification the session absorbed mid-turn that the caller never observed. A refusal arrives as an `error_during_execution` result whose message starts with `Resume rejected by --resume-drops-turn:`. Emitted in equals form so the value can never parse as a separate flag, and gated on `.nil?` rather than truthiness: an empty string is forwarded for the CLI to reject as a malformed declaration, since dropping it here would silently disarm the guard the caller believes is armed. No SDK-side validation of the option combination — like the TypeScript and Python SDKs, that is the CLI's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UuAH421iQ9oMARmFsMFfP
Port of the stdin-lifecycle half of Python SDK PR #1204 (sha fcdae22). wait_for_result_and_end_input decided whether the CLI might still send a control_request needing a reply by looking only at @sdk_mcp_servers and @hooks. A permission callback is served over the very same channel, so an Enumerator prompt with can_use_tool and neither hooks nor SDK MCP servers closed stdin the moment input ended — every later permission control_request then failed CLI-side with "Stream closed". That combination is a supported usage that has been broken. Extracted the check as #bidirectional_needs? (the TS SDK's hasBidirectionalNeeds, de-prefixed per Ruby naming) covering all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ojRwGumL91UH627ZPAnjZ
Port of the entry-point half of Python SDK PR #1204 (sha fcdae22). query() and Client#connect each carried their own copy of the can_use_tool validation, and query() refused a String prompt outright with "can_use_tool callback requires streaming mode". That restriction was never real: the SDK is always streaming internally — a String prompt is written to stdin as a user message like any other — so with stdin held open for the turn (the previous commit) the permission round-trip works fine. - ClaudeAgentSDK.configure_can_use_tool(options), next to the other helpers shared by both entry points: returns options unchanged with no callback; otherwise enforces mutual exclusion with permission_prompt_tool_name, emits the shadowing advisory, and returns a dup_with permission_prompt_tool_name: 'stdio'. - Both entry points now call it; the streaming-mode raise and the duplicated checks are gone. - Ruby's wrote_message guard in stream_input is deliberately kept as-is: it already covers what Python added here (a `written` counter), and it is better — a stream that never produced a complete message closes stdin instead of waiting for a result that can never come. Python still documents that path as a limitation. - Documented Python's remaining known limitation on wait_for_result_and_end_input: the condition is one-shot and unaware of prompts still queued CLI-side, so a multi-message Enumerator prompt releases the hold at the first turn boundary. Not fixed here. The new query() specs drive a permission-gated fake transport that models the real CLI contract (permission request only after the user message, assistant/result only after the verdict, writes after end_input raise, and stdin EOF ends the process) for both String and Enumerator prompts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ojRwGumL91UH627ZPAnjZ
Follow-up to the lane's four feature commits: all three fixes are documentation-only, no behavior change. - MessageOrigin: the shape/known-kinds prose sat in a free-floating comment block anchored to no object, so it rendered nowhere and both `@see` tags dangled. Folded into UserMessage#origin (full text) and ResultMessage#origin (usage + key form), which YARD now renders on the readers. The load-bearing sentence — keys are Symbols with the wire spelling preserved, so you index `origin[:fromSession]`, and a Python-style `origin["kind"]` silently returns nil and makes every attributed turn look unattributed — appears in both. - resume_drops_turn: had only a bare attr_accessor; the prose lived on a private command_builder method. Documented on the option itself, per Python #1198: how to choose the fork point, why forking at an assistant UUID is refused under structured output / end-turn MCP tools, the `Resume rejected by --resume-drops-turn:` contract and that a refusal is deterministic (clear the fork target, don't retry), and the empty-string pass-through. - forward_subagent_text: the docstring was on the writer only, so the reader rendered empty; moved to a documented attr_reader. Also dropped the claim that it "only applies in streaming/Client mode" — wrong here, since ClaudeAgentSDK.query runs the control protocol too and sends the capability on both paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UuAH421iQ9oMARmFsMFfP
Addresses adversarial review of the two preceding commits. 1. An `agent-<id>.meta.json` sidecar holding illegal UTF-8 bytes broke every later resume. `read_agent_metadata_sidecar` promised that a corrupt sidecar degrades to an absent one, but Ruby's `JSON.parse` accepts illegal bytes inside an otherwise well-formed UTF-8-tagged document and hands back a Hash whose values only blow up at `JSON.generate` time (Python's `read_text(encoding="utf-8")` raises instead, so it never got that far). `import_session_to_store` therefore persisted an `agent_metadata` entry built from it, and every subsequent resume through that store died re-serializing the sidecar in `write_subagent_files` — the error propagated out of `materialize_resume_session` past callers that don't rescue it, failing query/Client with an opaque encoding message until the store was repaired by hand. The reader now rejects invalid encoding up front, using the same `valid_encoding?` gate `parse_settings_bytes` already applies to settings bytes. 2. A FIFO where the sidecar is expected hung `get_subagent_messages` forever: `File.read` blocks with no writer, so the caller's best-effort `rescue SystemCallError` never fires because nothing is raised. This diff introduced the hang by opening a sidecar on a path that never opened one before. The reader now stats first and reads only a regular file, the same guard `read_if_present` uses on the resume seed files. 3. `write_redacted_credentials` could abort a resume, and its comment described behavior it did not have. For the same lenient-parse reason as (1), a `.credentials.json` with illegal bytes and a `refreshToken` key parses fine, redacts fine, then raises `JSON::GeneratorError` — which the `rescue JSON::ParserError` did not cover — so whether the resume aborted or passed the file through hinged on whether the file happened to carry a `refreshToken`. The redaction now lives in `redacted_credentials`, which returns the bytes to write or nil to write nothing: unparseable content still passes through, but a redaction that cannot be re-serialized seeds no credentials file at all. Writing the original bytes through would put the un-redacted refresh token back into the temp dir — the one thing this function exists to prevent — and raising would abort a resume every other seed file is careful not to abort. The comment now matches. 4. A lone surrogate escape (`"\ud800"`) in settings.json made the strip a no-op. Ruby's JSON parser rejects lone surrogates with no option to relax it, so the file fell through to a byte-for-byte copy carrying `enabledPlugins` — leaving exactly the network-install-every-resume behavior PR #1197 removes (Python's parser accepts them, so it strips normally). `parse_settings_bytes` now retries through `mask_surrogate_escapes`, which swaps `\uD800`-`\uDFFF` escapes for opaque ASCII tokens before parsing and restores them verbatim after re-serialization. Backslash parity is respected, so an escaped backslash followed by literal `uXXXX` text is left alone. The masking runs only as a fallback after a plain parse has already failed. Each fix has a test reproducing the reported scenario; all four were confirmed to fail against the previous commits.
…e helpers
Review round on the four Lane B commits.
必修 1 — can_use_tool: false was internally inconsistent. Query#bidirectional_
needs? tested `!@can_use_tool.nil?` while ClaudeAgentSDK.configure_can_use_tool
tests truthiness, so a config layer writing `can_use_tool: enabled ? cb : false`
got neither the stdio routing nor the mutual-exclusion check, yet still had
stdin held open for a reply the CLI would never be asked to produce —
stream_input parks forever. Now truthy on both sides, matching Python's
`... or self.can_use_tool`. The new spec is time-bounded: the regression hangs
rather than fails, and the ensure in wait_for_result_and_end_input calls
end_input even on timeout, so "did not time out" is the real assertion.
必修 2 — both new methods had been inserted *inside* track_task_lifecycle's
doc comment, splitting "Only delegated agent work is tracked ... A" from
"background *shell* is also reported ...", which then read as documentation
for error_result_text. Comment restored; bidirectional_needs? moved below
track_task_lifecycle.
建议修 3 — ResultError.normalize_errors / .field were public class methods
only because query.rb reached for them. Both now live in a private_constant
Payload module, and the text chain moves to ResultError.error_text, next to
the fields it must agree with (that agreement is the whole reason they share
readers). query.rb calls the single public entry point; error_result_text is
gone. .error_text stays public deliberately: it is how the read loop builds
the message, and the documented way to summarize a raw is_error result frame
you already hold.
Also taking the two recorded suggestions:
- A pending-control-request case driven by `initialize` rather than
`interrupt` — the literal shape of a resume refused before the handshake
is answered, which is where Lane A meets this path.
- The two hand-run smokes landed as integration specs: can_use_tool over the
control protocol for both String and Enumerator prompts, and the typed
ResultError on a CLI exit after an error result (budget cap is the cheapest
deterministic trigger). They follow the file's self-skip convention. The
permission specs assert the callback fired and the run still reached a
non-error result — whether the model picks Write or Bash is not the SDK's
business, and the unit specs pin the exact wire exchange.
Verified against the real CLI: all three new integration examples pass, and
the String-prompt one fails ("expected the CLI to ask can_use_tool at least
once") with the bidirectional_needs? fix reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ojRwGumL91UH627ZPAnjZ
Adds docs/ coverage for everything the three sync lanes introduced — the lanes deliberately left README/CHANGELOG/docs alone so the release notes could be written once, against the integrated result. - docs/types.md: ConversationResetMessage, and a Message Origin section spelling out that Ruby's origin Hash is Symbol-keyed with the CLI's camelCase preserved (origin[:fromSession]) — the Python SDK's equivalent is string-keyed, so porting origin["kind"] literally reads nil and misclassifies every injected turn as unattributed. - docs/errors.md: ResultError, its field set, the message-text precedence, and the rescue-before-ProcessError ordering. - docs/sessions.md: resume_drops_turn, the fork-point rule, the deterministic refusal contract; subagent parent ids. - docs/configuration.md: forward_subagent_text. - examples/message_types_example.rb: handle ConversationResetMessage. - README: message-type count 24 -> 25. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dUsxYfSqkXCVwTUbNoMyB
The conversation_reset frame was previously dropped by the parser's forward-compat fallthrough, so consumers that raise on an unrecognized message class start seeing it — on the first /clear of a long-lived session. Python #1196 flagged the same widening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dUsxYfSqkXCVwTUbNoMyB
ya-luotao
added a commit
that referenced
this pull request
Aug 28, 2026
version.rb and CHANGELOG were bumped in #55; this covers the remaining lockstep sites (README pin, plugin.json) and the skill trees. Two skill claims went stale with #55 and are corrected rather than merely extended: options.md and troubleshooting.md both said can_use_tool is unsupported by ClaudeAgentSDK.query, which is exactly what #55 changed. Also documents resume_drops_turn, forward_subagent_text, ConversationResetMessage, message origin (Symbol keys, camelCase kept — the Python SDK's is string-keyed), and ResultError on the resume path; and records that settings-file rules auto-approve invisibly, since the SDK's shadowing warning cannot see them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019dUsxYfSqkXCVwTUbNoMyB
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.
Syncs the gem from Python SDK 0.2.134 to 0.2.147. The intervening releases 0.2.135/136/138/139/141–147 only bump the CLI binary Python vendors; this gem does not ship a CLI, so they carry no Ruby-side change. All functional work lives in 0.2.137 and 0.2.140.
Built in three parallel worktrees (messages / errors / sessions), each reviewed by two independent adversarial reviewers before merge. Merged with zero conflicts.
Ported from Python
ConversationResetMessagefor theconversation_resetframeoriginonUserMessage/ResultMessageresume_drops_turn; enriched error for in-flight control requestssettings.json/cowork_settings.jsonon store-backed resumeforward_subagent_textResultErrorwith the structured error payloadcan_use_toolwith a String prompt; stdin held open for itparent_tool_use_id/ addparent_agent_idfor subagent transcriptsTwo live defects fixed that predate this sync
can_use_toolpermission requests were cut off when it was the only bidirectional feature in use. The check deciding whether the CLI may still send control requests needing a reply looked only at SDK MCP servers and hooks, so anEnumeratorprompt usingcan_use_toolwith neither closed stdin as soon as input ended — later permission requests then failed CLI-side with "Stream closed". This broke an already-supported path.initialize, discarding the CLI's actual reason. Pending control requests were signalled with the raw exception before the enriched one was computed.Ruby-only hardening (from adversarial review, no Python counterpart)
These came out of the review rounds; reviewers will not find them in any Python PR.
JSON.parseis lenient about illegal bytes inside a UTF-8-tagged document, returning a Hash whose values only fail later at generate time. Session import persisted that as anagent_metadataentry, and every subsequent resume through the store then died with an opaqueJSON::GeneratorError— permanently, until the entry was repaired externally. Python degrades gracefully here becausedecode("utf-8")raises up front.get_subagent_messagesforever with no exception for the best-effort rescue to catch.settings.jsonwith a lone surrogate escape is no longer copied through unstripped. Ruby'sJSON.parserejects lone surrogates, which fell back to byte-for-byte passthrough — soenabledPluginssurvived and the resumed CLI kept network-installing marketplaces on every resume, the exact misbehavior the seeding transform exists to prevent. Python'sjson.loadsaccepts them, so it never had this hole..credentials.jsonno longer aborts a resume on a serialization failure; the rescue covered only parse failures.Compatibility
ConversationResetMessagewidens theMessageunion with a frame that was previously dropped silently. Code that raises on an unrecognized message class (case msg ... else raise) will now see it, on the first/clearof a long-lived session.ResultErrorsubclassesProcessError, so existingrescue ProcessErrorhandlers keep working — rescueResultErrorfirst to reach the structured fields.Verification
bundle exec rakeon the integrated branch: 1372 examples, 0 failures; RuboCop 66 files, no offenses. Baseline onmainwas 1296.RUN_INTEGRATION=1self-skips on the missingANTHROPIC_API_KEY): String prompt +can_use_toolfull permission round-trip (callback invoked, decision written back over stdin, file written, clean result), and a liveResultErrorcarryingerror_max_turns.spec/integration/now hascan_use_toolcontrol-protocol coverage, which the repo previously lacked entirely.Not in this PR
Python #1218 (mcp 2.x support) is deliberately deferred — Ruby's
mcpgem is a different ecosystem with no 2.x break, but the PR carries four embedded behaviors Ruby genuinely lacks (hand-rolled dispatch instead of the gem's own for everything buttools/call, a hardcodedprotocolVersion, nonotifications/cancelledhandling, no cancellation of in-flight calls on disconnect). Ruby'smcpgem exposes no in-memory transport, so the fix cannot mirror Python's approach and does not belong in a version-sync batch. Filed separately.🤖 Generated with Claude Code
https://claude.ai/code/session_019dUsxYfSqkXCVwTUbNoMyB