Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- Internal: `ClaudeAgentSDK.query` and `Client` now share one implementation of session setup, prompt writes, message delivery and teardown (`SessionAssembly`, `@api private`) instead of each carrying its own copy, and a session's observers, `callback_scheduling` and `callback_wrapper` are held in one object (`Dispatch`, `@api private`) instead of being passed at every call site; `Client` still captures scheduling and wrapper when it is constructed and resolves its observers on each `connect`. Public signatures and the documented differences between the two entry points (what observers are told when a session cannot be opened, the `session_id` on the wire, when stdin is closed, which error wins when a close raises) are preserved, and are now pinned by specs that run both side by side. One cleanup difference: when a `Client` is reconnected while its previous `disconnect` is still removing the materialized directory of a store-backed resume, that `disconnect`, once it finishes, no longer clears the new session, so the new session's resume directory is removed by its own `disconnect` (it used to be left behind).
### Internal
- The decision of when `query()` may close stdin moved out of `Query` into one private class, `Query::RunLifecycle` (`lib/claude_agent_sdk/query/run_lifecycle.rb`): the per-run end, the task ledger, the session state and the ceiling timer, with the orderings between them. `Query` hands it five events, including frames, and one wait, and supplies the timer. No behavior change; nothing public moved. Its specs now go through that interface (`spec/unit/query/run_lifecycle_spec.rb`) instead of reaching into `Query`'s instance variables.
- Hash-form option interpretation moved into one private module (`OptionForms`); no behaviour change.
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ User code

- **`Client`** — Full-featured bidirectional sessions. Accepts optional `transport_class` (defaults to `SubprocessCLITransport`) and `transport_args` for custom transports. Creates transport, instantiates a `Query` handler that runs `read_messages` in an async task, routes control messages internally, and exposes SDK messages via `Async::Queue`. Supports hooks, permission callbacks, SDK MCP servers, interrupt, model switching, and file rewind.

Both are lifecycle adapters over the `@api private` `SessionAssembly` (`lib/claude_agent_sdk/session_assembly.rb`; `Dispatch`, in the same file, holds a session's observers / `callback_scheduling` / `callback_wrapper` so no call site passes them by hand — `Client` keeps one for its lifetime and puts the observers it resolves in on each connect, so each notification reads the current ones): the one shared implementation of acquisition (materialize a store-backed resume → repoint the options → construct and connect the transport → the only `Query.new` → transcript mirror → `start` → handshake), prompt writes, delivery (the only receive loop, `#deliver`) and disposal (`#close_resources`), which never notifies `on_error` or `on_close` (it does dispatch `on_message`, in `#deliver`) and rescues nothing — when those two fire, which error wins and whether stdin closes stay in the two adapters, and so does `Client`'s state across a teardown (`#close_resources` yields once both closes are behind and before the temp dir is removed, where `Client` marks itself disconnected; a session whose teardown raised is kept until a disconnect completes; `spec/unit/client_teardown_interleavings_spec.rb`) (`spec/unit/entry_point_lifecycle_spec.rb` pins those differences; `spec/unit/entry_point_parity_spec.rb` runs a set of scenarios through both and pins them as identical).

### Run Lifecycle (when stdin may close)

`Query::RunLifecycle` (`lib/claude_agent_sdk/query/run_lifecycle.rb`, `@api private`) owns the one decision *when may stdin close?*: the CLI's `session_state_changed` `idle` after a result (the transport sets `CLAUDE_CODE_SDK_READS_SESSION_STATE=1`, and `Query` drops the `sdk_host_only` frames that brings), bounded between turns by `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` (10 min; a main-thread turn, `requires_action` or a tracked agent stops the clock); from a CLI that sends no state, the first result with no tracked task in flight (the #1088 ledger).
Expand Down Expand Up @@ -118,7 +120,7 @@ The public functions in `lib/claude_agent_sdk.rb` (`list_sessions`, `rename_sess

### FiberBoundary (fiber safety)

`async` installs a Fiber scheduler, but most Ruby libraries (pg, mysql2, ActiveRecord pools) key state on `Thread.current` and are thread-safe, not fiber-safe. `FiberBoundary.invoke` (`lib/claude_agent_sdk/fiber_boundary.rb`) hops every user-supplied callback (tool handlers, hooks, permission callbacks, message blocks, observers) to a plain thread before invoking it. Control-request callbacks (hooks, `can_use_tool`, SDK MCP tool/resource/prompt handlers) go through `FiberBoundary.invoke_callback`: a `SystemExit` / `SignalException` raised by the callback is carried out of the hop, `Query#handle_control_request` writes the normal error response, then re-raises the original — the CLI always gets its answer and process termination is never swallowed (#119). Consequence: the thread hop severs `break`/`return`/`next` from the surrounding method — SDK loops yielding user callbacks must keep loop control outside the invoked block (see `Client#receive_response`; user `break` is bridged via `.invoke_iteration`).
`async` installs a Fiber scheduler, but most Ruby libraries (pg, mysql2, ActiveRecord pools) key state on `Thread.current` and are thread-safe, not fiber-safe. `FiberBoundary.invoke` (`lib/claude_agent_sdk/fiber_boundary.rb`) hops every user-supplied callback (tool handlers, hooks, permission callbacks, message blocks, observers) to a plain thread before invoking it. Control-request callbacks (hooks, `can_use_tool`, SDK MCP tool/resource/prompt handlers) go through `FiberBoundary.invoke_callback`: a `SystemExit` / `SignalException` raised by the callback is carried out of the hop, `Query#handle_control_request` writes the normal error response, then re-raises the original — the CLI always gets its answer and process termination is never swallowed (#119). Consequence: the thread hop severs `break`/`return`/`next` from the surrounding method — SDK loops yielding user callbacks must keep loop control outside the invoked block (see `SessionAssembly#deliver`, the receive loop behind `query()` and `Client#receive_messages` / `#receive_response`; user `break` is bridged via `.invoke_iteration`).

Opt-in `ClaudeAgentOptions#callback_scheduling: :inline` (issue #47) skips the hop and runs callbacks in place on the reactor fiber — for hosts that are fiber-isolated end to end (solid_queue fiber workers with `IsolatedExecutionState.isolation_level = :fiber`). The mode is plumbed per-call (Query kwarg; the SDK-MCP dispatch path carries it via fiber storage — `Fiber[FiberBoundary::DISPATCH_KEY]` — so a server shared by concurrent sessions is never mutated), never via a thread-local. Timeout-bounded store-adapter calls hop by default regardless of session mode — unless the ADAPTER declares itself fiber-native via an optional `callback_scheduling` method returning `:inline` (probed by `SessionStores.store_callback_scheduling` at batcher construction / resume-materialization entry; issue #47 phase 3), in which case they run in place under a cooperative timeout that still surfaces as `JoinTimeout`. Inline hook timeouts are cooperative (`with_timeout`). Public `ClaudeAgentSDK.offload { }` hops one heavy block manually. `ClaudeAgentOptions#callback_wrapper` (issue #47 phase 2) is optional middleware composed around every user-callback dispatch BEFORE the hop, so it runs on the callback's own execution context (worker thread in `:thread`, reactor fiber in `:inline`) — in Rails, `ClaudeAgentSDK::Railtie.callback_wrapper` (see Rails Integration; a bare `executor.wrap` lambda deadlocks under dev code reloading); it travels the SDK-MCP dispatch path inside the same fiber-storage scope as the mode, and also composes around timeout-bounded store-adapter dispatch (batcher appends, resume-materialization calls) inside the bound. `SdkMcpServer#callback_scheduling=` / `#callback_wrapper=` validate like their `ClaudeAgentOptions` counterparts (String coerced, invalid values raise).

Expand Down
Loading
Loading