claude-agent-sdk 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/docs/client.md +11 -0
- data/docs/configuration.md +42 -0
- data/lib/claude_agent_sdk/cli_installer.rb +1 -1
- data/lib/claude_agent_sdk/query.rb +320 -56
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +25 -0
- data/lib/claude_agent_sdk/types/messages.rb +7 -1
- data/lib/claude_agent_sdk/types/options.rb +69 -12
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +17 -7
- data/sig/claude_agent_sdk/types/options.rbs +9 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecc2b309c23e9e07ce4d03aed2997d7f8c2b3fc8909f48601bfd3c1b0ca93152
|
|
4
|
+
data.tar.gz: f7c56c6cde059b2edf3b57e633ac49fa761074d633a9532f503ff2261b846795
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 422bd92af76c1785ce33e3c50b7750d74c4dbdeb096445a0f441497be2e3f5f41a463cabd7f55374606fa236c524ae356e4a282cd75db9c7fde3fbbd4e8e07e2
|
|
7
|
+
data.tar.gz: 17b52f5f14f603cadb4fdc989b4236a5d75c4862f1ec8672014548288a67f886ede5695afce8078327d779331da87b82cc5fbfcc3fadee419037615ce7bc134b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.1.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
Syncs with Python SDK 0.2.162. Additive only: one new option and a fix to when `query()` closes stdin.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`ClaudeAgentOptions#verbatim_prompts`** (default `false`; Python SDK #1269). When `true`, every user message the SDK writes carries `client_composed: true`: String prompts and each message of a streamed prompt, through `query`, `Client#connect` and `Client#query`. Claude Code then delivers the text as written, with no `@path` file expansion and no slash-command dispatch. Use it when a prompt includes text your end user did not type. `tools: []` and `disallowed_tools` do not stop `@path` expansion, which reads the file before the model runs. While the option is on, a caller's `client_composed` key is overwritten, and caller Hashes are never mutated. A streamed JSONL String is parsed and re-serialized; one that is not a single JSON object raises `ArgumentError` rather than going out unmarked. `Client` reads the option once, at `connect`. On current CLIs such a turn also skips the turn-start attachment pass (nested `CLAUDE.md` and rules files, skill and tool listings). Requires Claude Code 2.1.248 or later; the SDK warns when it connects to an older CLI with the option on. See `docs/configuration.md`.
|
|
16
|
+
- Development only, nothing ships in the gem: TLA+ models in `formal/tla/` of the two most concurrency-sensitive designs. `CLIInstaller.tla` covers `CLIInstaller.install`'s publish order, the in-lock dist-tag resolve and the stale-temp sweep. `ControlProtocol.tla` covers the outbound control-request waiter protocol in `Query`. `formal/tla/run.sh` model-checks both with TLC. It confirms that the shipped design passes and that each alternative the source comments reject, including the historical rename-then-record order, produces a counterexample.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- `CLIInstaller::PINNED_CLI_VERSION` moves from 2.1.280 (the 1.0.0 pin) to **2.1.285**, the CLI the Python SDK 0.2.162 bundles. `CLIInstaller.install_pinned` installs it. It honors both `client_composed` (`verbatim_prompts`) and `CLAUDE_CODE_SDK_READS_SESSION_STATE` (the fix below).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **`query()` with hooks, `can_use_tool` or SDK MCP servers no longer closes stdin while a follow-up turn is still owed** (Python SDK #1279, fixing Python issue #1190). A background subagent that finished just before the turn's result was already off the in-flight ledger, so stdin closed at that result, and the follow-up turn its completion triggered had every hook, permission and SDK MCP request fail with "Stream closed" (the model reported the tool as refused). The transport now sets `CLAUDE_CODE_SDK_READS_SESSION_STATE=1` unless `options.env` or the environment already names it (in any case; a `nil` value in `options.env` unsets it). The CLI then sends `session_state_changed` frames marked `sdk_host_only`, which the SDK drops from the message stream for `query()` and `Client` alike. `query()` keeps stdin open until the CLI reports `idle` after a result:
|
|
23
|
+
- The wait between turns is bounded by `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS`, read from `options.env` and then the environment; the default is 10 minutes and `0` means no limit. The clock restarts at each result and at each `running`. A main-thread turn, the CLI reporting `requires_action`, a tracked background agent and a hook, permission or SDK MCP request the SDK is still answering each stop it.
|
|
24
|
+
- With an Enumerable prompt, each message written waits for its own run. Work the CLI takes up after the run ended reopens it.
|
|
25
|
+
- A CLI that sends no state (2.1.282 and earlier) gets the old behavior: stdin closes at the first result with no tracked task in flight.
|
|
26
|
+
- Background agents, bounded monitors and MCP tasks can now keep a one-shot run open, up to the ceiling. Background shells, persistent monitors and remote agents do not, because the CLI leaves them out of its `running` report.
|
|
27
|
+
- `SessionStateChangedMessage` reaches your code only if you set `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` yourself; the SDK never sets it.
|
|
28
|
+
- A custom transport must pass `CLAUDE_CODE_SDK_READS_SESSION_STATE=1` to the CLI itself to get this behavior (see `docs/client.md`); the E2B example transport now does.
|
|
29
|
+
|
|
10
30
|
## [1.0.0] - 2026-09-23
|
|
11
31
|
|
|
12
32
|
**1.0 is a stability commitment.** From here on the public API — everything documented in `docs/` plus the YARD docs without `@api private`, now also described by the RBS signatures in `sig/` — follows Semantic Versioning: no breaking changes within 1.x. Upgrading from 0.x? Read [UPGRADING-1.0.md](UPGRADING-1.0.md) and run your suite on 0.37 first.
|
data/docs/client.md
CHANGED
|
@@ -117,6 +117,17 @@ A transport must implement six methods:
|
|
|
117
117
|
| `close` | Terminate and clean up |
|
|
118
118
|
| `ready?` | Report whether the transport can accept I/O |
|
|
119
119
|
|
|
120
|
+
**Environment your transport should give the CLI.** `SubprocessCLITransport`
|
|
121
|
+
sets a few variables that a custom transport has to set itself. The one that
|
|
122
|
+
changes SDK behavior is `CLAUDE_CODE_SDK_READS_SESSION_STATE=1`: with it, the
|
|
123
|
+
CLI reports its session state, and a one-shot `query()` with hooks,
|
|
124
|
+
`can_use_tool` or SDK MCP servers keeps stdin open until the CLI reports
|
|
125
|
+
`idle`. That is what lets a follow-up turn woken by a background subagent get
|
|
126
|
+
its control requests answered. Without it, `query()` closes stdin at the first
|
|
127
|
+
result with no tracked task in flight, the pre-1.1 behavior. The state frames
|
|
128
|
+
arrive marked `sdk_host_only`, and the SDK drops them from your message
|
|
129
|
+
stream.
|
|
130
|
+
|
|
120
131
|
Then plug it into `Client` via `transport_class:` / `transport_args:`. All connect orchestration (option transforms, MCP extraction, hook conversion, Query lifecycle) is handled for you.
|
|
121
132
|
|
|
122
133
|
```ruby
|
data/docs/configuration.md
CHANGED
|
@@ -245,6 +245,48 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
|
|
|
245
245
|
|
|
246
246
|
See [examples/bare_mode_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb).
|
|
247
247
|
|
|
248
|
+
## Verbatim Prompts
|
|
249
|
+
|
|
250
|
+
Claude Code expands an `@/absolute/path` token anywhere in a user message into
|
|
251
|
+
that file's contents, and dispatches a leading `/name` as a slash command. The
|
|
252
|
+
expansion happens before the model runs and without a tool call, so `tools: []`,
|
|
253
|
+
`allowed_tools` and `disallowed_tools` do not stop it. If your prompt includes
|
|
254
|
+
text your end user did not type (earlier turns, tool output, third-party
|
|
255
|
+
content), set `verbatim_prompts` so that text cannot make Claude Code read a
|
|
256
|
+
local file:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
options = ClaudeAgentSDK::ClaudeAgentOptions.new(verbatim_prompts: true)
|
|
260
|
+
ClaudeAgentSDK.query(prompt: text_that_may_contain_at_paths, options: options) { |message| ... }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Every user message the SDK writes is then marked `client_composed`, and Claude
|
|
264
|
+
Code delivers it exactly as written. That covers String prompts and every
|
|
265
|
+
message of a streamed prompt, through `ClaudeAgentSDK.query`, `Client#connect`
|
|
266
|
+
and `Client#query`.
|
|
267
|
+
|
|
268
|
+
- **No per-message opt-out.** A `client_composed` key on a streamed message
|
|
269
|
+
Hash is overwritten. For per-turn control, leave the option off and set
|
|
270
|
+
`client_composed: true` on individual streamed messages.
|
|
271
|
+
- Your message Hashes are never mutated.
|
|
272
|
+
- A streamed JSONL String is parsed, marked and re-serialized. One that is not
|
|
273
|
+
a single JSON object raises `ArgumentError` instead of going out unmarked
|
|
274
|
+
(on the background streaming paths the stream stops with a warning, like any
|
|
275
|
+
stream error).
|
|
276
|
+
- `Client` reads the option once, at `connect`.
|
|
277
|
+
- **It skips more than `@path` expansion.** On current Claude Code versions a
|
|
278
|
+
turn delivered this way skips the whole turn-start attachment pass:
|
|
279
|
+
`@server:resource` MCP mentions are not expanded, and the prompt goes without
|
|
280
|
+
the context Claude Code normally attaches (nested `CLAUDE.md` and rules files,
|
|
281
|
+
skill and tool listings, other per-turn reminders). The pass between tool
|
|
282
|
+
calls still runs, so most of that context arrives after the turn's first tool
|
|
283
|
+
call.
|
|
284
|
+
- Requires Claude Code **2.1.248** or later. Older versions ignore the field
|
|
285
|
+
and still expand prompts; the SDK prints a warning when it connects to one
|
|
286
|
+
with the option on.
|
|
287
|
+
|
|
288
|
+
Matches the Python SDK's `verbatim_prompts`.
|
|
289
|
+
|
|
248
290
|
## Forwarding Subagent Text
|
|
249
291
|
|
|
250
292
|
By default only `tool_use` / `tool_result` blocks from subagents (spawned via
|
|
@@ -53,7 +53,7 @@ module ClaudeAgentSDK
|
|
|
53
53
|
# Single source of truth: bumped here (and only here) by
|
|
54
54
|
# .github/workflows/cli-pin-bump.yml or a Python-sync release, so a
|
|
55
55
|
# Dependabot bump of the gem carries the CLI forward with it.
|
|
56
|
-
PINNED_CLI_VERSION = '2.1.
|
|
56
|
+
PINNED_CLI_VERSION = '2.1.285'
|
|
57
57
|
# @api private
|
|
58
58
|
BINARY_NAME = 'claude'
|
|
59
59
|
# @api private
|
|
@@ -49,6 +49,93 @@ module ClaudeAgentSDK
|
|
|
49
49
|
# status, or it will hang the query (see #track_task_lifecycle).
|
|
50
50
|
DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
|
|
51
51
|
|
|
52
|
+
# The CLI's own wait for background work once stdin is closed; the SDK
|
|
53
|
+
# bounds its wait for the CLI's "idle" by the same value
|
|
54
|
+
# (#arm_run_end_ceiling, Python #1279).
|
|
55
|
+
RUN_END_CEILING_ENV_VAR = 'CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS'
|
|
56
|
+
DEFAULT_RUN_END_CEILING_MS = 600_000
|
|
57
|
+
# The longest ceiling honored (~24.8 days), as in the TypeScript and
|
|
58
|
+
# Python SDKs, whose timers cannot run longer.
|
|
59
|
+
MAX_RUN_END_CEILING_MS = (2**31) - 1
|
|
60
|
+
# Frame types that mark a main-thread turn under way (when they carry no
|
|
61
|
+
# parent_tool_use_id), and the states that do not re-arm the ceiling.
|
|
62
|
+
TURN_FRAME_TYPES = %w[assistant stream_event].freeze
|
|
63
|
+
NON_RUNNING_SESSION_STATES = %w[idle requires_action].freeze
|
|
64
|
+
|
|
65
|
+
# Read CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS from where the CLI gets it:
|
|
66
|
+
# +options_env+ (ClaudeAgentOptions#env) overrides the inherited
|
|
67
|
+
# environment, as it does for the subprocess, and a key present with a nil
|
|
68
|
+
# value is unset in the child, so the CLI falls back to its default. 0
|
|
69
|
+
# means no limit. Only plain non-negative integers are read; anything else
|
|
70
|
+
# falls back to the CLI's default of 10 minutes, including spellings the
|
|
71
|
+
# CLI itself also reads, such as 1e6 (as in the Python SDK).
|
|
72
|
+
def self.run_end_ceiling_ms(options_env)
|
|
73
|
+
env = (options_env || {}).transform_keys(&:to_s)
|
|
74
|
+
raw = env.key?(RUN_END_CEILING_ENV_VAR) ? env[RUN_END_CEILING_ENV_VAR] : ENV.fetch(RUN_END_CEILING_ENV_VAR, nil)
|
|
75
|
+
digits = raw.to_s.strip
|
|
76
|
+
digits.match?(/\A\d+\z/) ? Integer(digits, 10) : DEFAULT_RUN_END_CEILING_MS
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# One run's end, set at most once (the Python SDK's per-run anyio.Event).
|
|
80
|
+
# A waiter holds the object it started waiting on, so a run that ends and
|
|
81
|
+
# is then reopened (#reopen_run swaps in a fresh RunEnd) still releases the
|
|
82
|
+
# waiters the ended run woke, while later waits wait for the new run.
|
|
83
|
+
# Reactor-only, like every caller.
|
|
84
|
+
class RunEnd
|
|
85
|
+
def initialize
|
|
86
|
+
@ended = false
|
|
87
|
+
@condition = Async::Condition.new
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ended?
|
|
91
|
+
@ended
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def end!
|
|
95
|
+
return if @ended
|
|
96
|
+
|
|
97
|
+
@ended = true
|
|
98
|
+
@condition.signal
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wait
|
|
102
|
+
@condition.wait until @ended
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Apply ClaudeAgentOptions#verbatim_prompts to one outgoing user message
|
|
107
|
+
# (Python #1269's stamp_user_message). Off: returns +message+ unchanged.
|
|
108
|
+
# On: returns a new Hash with `client_composed: true`, dropping any
|
|
109
|
+
# caller-supplied value under either key spelling first (JSON.generate
|
|
110
|
+
# would otherwise emit the key twice). A pre-serialized JSONL String — a
|
|
111
|
+
# Ruby-only input shape — is parsed, marked and handed back as a Hash; one
|
|
112
|
+
# that is not a single JSON object raises ArgumentError, because sending it
|
|
113
|
+
# unmarked would silently defeat the option.
|
|
114
|
+
def self.stamp_user_message(message, verbatim_prompts)
|
|
115
|
+
return message unless verbatim_prompts
|
|
116
|
+
|
|
117
|
+
hash = message.is_a?(Hash) ? message : parse_streamed_message(message.to_s)
|
|
118
|
+
hash.reject { |key, _| key.to_s == 'client_composed' }.merge(client_composed: true)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The serialized line for one outgoing user message, stamped per
|
|
122
|
+
# verbatim_prompts. Hashes are JSON-generated; other items pass through
|
|
123
|
+
# as their String form unless they must be stamped.
|
|
124
|
+
def self.serialize_user_message(message, verbatim_prompts)
|
|
125
|
+
stamped = stamp_user_message(message, verbatim_prompts)
|
|
126
|
+
stamped.is_a?(Hash) ? JSON.generate(stamped) : stamped.to_s
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.parse_streamed_message(string)
|
|
130
|
+
parsed = JSON.parse(string)
|
|
131
|
+
return parsed if parsed.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (got #{parsed.class})"
|
|
134
|
+
rescue JSON::ParserError => e
|
|
135
|
+
raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (#{e.message})"
|
|
136
|
+
end
|
|
137
|
+
private_class_method :parse_streamed_message
|
|
138
|
+
|
|
52
139
|
# Waiter for control responses awaited OFF the reactor — i.e. a control
|
|
53
140
|
# method called from inside a hook/can_use_tool/SDK-MCP callback, which
|
|
54
141
|
# runs on a FiberBoundary worker thread (Python supports this reentrancy
|
|
@@ -74,7 +161,8 @@ module ClaudeAgentSDK
|
|
|
74
161
|
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- initializes every control-protocol concern in one place
|
|
75
162
|
exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
|
|
76
163
|
forward_subagent_text: false, agent_progress_summaries: nil,
|
|
77
|
-
callback_scheduling: :thread, callback_wrapper: nil
|
|
164
|
+
callback_scheduling: :thread, callback_wrapper: nil, verbatim_prompts: false,
|
|
165
|
+
run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS)
|
|
78
166
|
@transport = transport
|
|
79
167
|
@is_streaming_mode = is_streaming_mode
|
|
80
168
|
@can_use_tool = can_use_tool
|
|
@@ -88,6 +176,8 @@ module ClaudeAgentSDK
|
|
|
88
176
|
@skills = skills
|
|
89
177
|
@forward_subagent_text = forward_subagent_text
|
|
90
178
|
@agent_progress_summaries = agent_progress_summaries
|
|
179
|
+
@verbatim_prompts = verbatim_prompts
|
|
180
|
+
@run_end_ceiling_ms = run_end_ceiling_ms
|
|
91
181
|
|
|
92
182
|
# Control protocol state
|
|
93
183
|
@pending_control_responses = {}
|
|
@@ -103,10 +193,30 @@ module ClaudeAgentSDK
|
|
|
103
193
|
|
|
104
194
|
# Message stream
|
|
105
195
|
@message_queue = Async::Queue.new
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
196
|
+
# Ends when the run is over, so the stdin-closing waiter can wake; see
|
|
197
|
+
# #read_messages and @inflight_tasks below (Python #1088, #1190/#1279).
|
|
198
|
+
# Work the CLI takes up after the run ended swaps in a fresh one
|
|
199
|
+
# (#reopen_run).
|
|
200
|
+
@run_end = RunEnd.new
|
|
201
|
+
@result_received = false
|
|
202
|
+
# The CLI's latest session_state_changed state, or nil while it sends
|
|
203
|
+
# none (a CLI too old to honor CLAUDE_CODE_SDK_READS_SESSION_STATE). A
|
|
204
|
+
# CLI that reports state stays "running" while a background agent is
|
|
205
|
+
# live or its completion is still to be handled, and reports "idle" once
|
|
206
|
+
# no further turn is owed.
|
|
207
|
+
@session_state = nil
|
|
208
|
+
# Ends the run if no new turn starts within the ceiling after a result
|
|
209
|
+
# (#arm_run_end_ceiling). The generation tells a sleeper that woke after
|
|
210
|
+
# it was cleared or re-armed to stand down.
|
|
211
|
+
@run_end_ceiling_task = nil
|
|
212
|
+
@run_end_ceiling_generation = 0
|
|
213
|
+
# A main-thread turn is under way (its assistant/stream_event frames
|
|
214
|
+
# have started and its result has not arrived): the ceiling counts only
|
|
215
|
+
# the wait between turns, so it is not armed meanwhile.
|
|
216
|
+
@turn_in_progress = false
|
|
217
|
+
# Set once stdin is closed or the reader is gone: the run then stays
|
|
218
|
+
# ended, since nothing can wait on a reopened one.
|
|
219
|
+
@run_final = false
|
|
110
220
|
# Task IDs of started-but-not-finished deferring tasks. A result frame
|
|
111
221
|
# only ends one turn, not the run: a background task keeps running past
|
|
112
222
|
# it and still needs stdin for hook/SDK-MCP control responses (Python
|
|
@@ -119,7 +229,6 @@ module ClaudeAgentSDK
|
|
|
119
229
|
# reported. Mirrors the TypeScript SDK's `lastErrorResultText`
|
|
120
230
|
# (Query.ts), but keeps the whole payload rather than just the text.
|
|
121
231
|
@last_error_result = nil
|
|
122
|
-
@first_result_condition = Async::Condition.new
|
|
123
232
|
@task = nil
|
|
124
233
|
@child_tasks = []
|
|
125
234
|
@initialized = false
|
|
@@ -376,22 +485,26 @@ module ClaudeAgentSDK
|
|
|
376
485
|
else
|
|
377
486
|
# Track task lifecycle frames so results can tell "one turn ended"
|
|
378
487
|
# apart from "the run is done" (Python #1088/#1103).
|
|
379
|
-
|
|
488
|
+
if msg_type == 'system'
|
|
489
|
+
had_tasks_in_flight = !@inflight_tasks.empty?
|
|
490
|
+
track_task_lifecycle(message)
|
|
491
|
+
# The ceiling left the last tracked agent alone; the wait between
|
|
492
|
+
# turns starts over now that it settled.
|
|
493
|
+
rearm_run_end_ceiling_between_turns if had_tasks_in_flight && @inflight_tasks.empty?
|
|
494
|
+
if message[:subtype] == 'session_state_changed'
|
|
495
|
+
on_session_state(message[:state])
|
|
496
|
+
# Frames the CLI sent only because the transport asked for them
|
|
497
|
+
# (CLAUDE_CODE_SDK_READS_SESSION_STATE); the caller did not opt
|
|
498
|
+
# in, so they never reach the stream, observers or the parser.
|
|
499
|
+
next if message[:sdk_host_only] == true
|
|
500
|
+
end
|
|
501
|
+
end
|
|
380
502
|
|
|
381
|
-
if
|
|
503
|
+
if msg_type == 'result'
|
|
382
504
|
# Flush the mirror before signaling/yielding the result so a
|
|
383
505
|
# consumer observing the result sees an up-to-date store for the turn.
|
|
384
506
|
flush_transcript_mirror
|
|
385
|
-
|
|
386
|
-
# the tasks may still need hook/SDK-MCP control responses over
|
|
387
|
-
# stdin, and closing it now silently disables hooks and fails
|
|
388
|
-
# SDK-MCP calls with "Stream closed". Each deferring task's
|
|
389
|
-
# completion wakes the parent for a follow-up turn, so a later
|
|
390
|
-
# result arrives with no tasks in flight and closes stdin then.
|
|
391
|
-
if @inflight_tasks.empty? && !@first_result_received
|
|
392
|
-
@first_result_received = true
|
|
393
|
-
@first_result_condition.signal
|
|
394
|
-
end
|
|
507
|
+
on_result
|
|
395
508
|
@last_error_result = message[:is_error] ? message : nil
|
|
396
509
|
elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
|
|
397
510
|
# Anything other than the post-turn session_state_changed marker
|
|
@@ -399,6 +512,14 @@ module ClaudeAgentSDK
|
|
|
399
512
|
# crash, not the expected exit from a prior error result. Mirrors
|
|
400
513
|
# the Python/TypeScript SDK reset logic.
|
|
401
514
|
@last_error_result = nil
|
|
515
|
+
# A main-thread turn is under way, so the ceiling stops (it counts
|
|
516
|
+
# only the wait between turns, as the CLI's does) and the run
|
|
517
|
+
# reopens even if the ceiling ended it while no state changed.
|
|
518
|
+
if TURN_FRAME_TYPES.include?(msg_type) && message[:parent_tool_use_id].nil?
|
|
519
|
+
@turn_in_progress = true
|
|
520
|
+
reopen_run
|
|
521
|
+
clear_run_end_ceiling
|
|
522
|
+
end
|
|
402
523
|
end
|
|
403
524
|
# Regular SDK messages go to the queue
|
|
404
525
|
@message_queue.enqueue(message)
|
|
@@ -447,10 +568,11 @@ module ClaudeAgentSDK
|
|
|
447
568
|
begin
|
|
448
569
|
flush_transcript_mirror
|
|
449
570
|
ensure
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
571
|
+
# Unblock the stdin-closing waiter so it doesn't stall on early exit;
|
|
572
|
+
# with the reader gone the run stays ended. Also stops a pending
|
|
573
|
+
# ceiling sleeper, which would otherwise keep the reactor alive.
|
|
574
|
+
@run_final = true
|
|
575
|
+
end_run
|
|
454
576
|
# Always signal end of stream
|
|
455
577
|
@message_queue.enqueue({ type: 'end' })
|
|
456
578
|
end
|
|
@@ -500,6 +622,131 @@ module ClaudeAgentSDK
|
|
|
500
622
|
end
|
|
501
623
|
end
|
|
502
624
|
|
|
625
|
+
# A result ends a turn, not necessarily the run: a background agent that
|
|
626
|
+
# finished just before it still wakes the session for another turn, whose
|
|
627
|
+
# hook, permission and SDK MCP requests need stdin (Python #1190/#1279). A
|
|
628
|
+
# CLI that reports session state stays "running" while such a turn is
|
|
629
|
+
# owed, so wait for "idle" (some hosts send it just before the result).
|
|
630
|
+
# Without state events the result is all there is to go on.
|
|
631
|
+
def on_result
|
|
632
|
+
@result_received = true
|
|
633
|
+
@turn_in_progress = false
|
|
634
|
+
if @session_state.nil? || @session_state == 'idle' || !bidirectional_needs?
|
|
635
|
+
maybe_end_run
|
|
636
|
+
elsif @session_state != 'requires_action'
|
|
637
|
+
# While the SDK is still answering a request the ceiling waits for
|
|
638
|
+
# the "running" that follows.
|
|
639
|
+
arm_run_end_ceiling
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Track the CLI's session_state_changed state (Python #1279).
|
|
644
|
+
def on_session_state(state)
|
|
645
|
+
@session_state = state
|
|
646
|
+
if state == 'idle'
|
|
647
|
+
maybe_end_run if @result_received
|
|
648
|
+
return
|
|
649
|
+
end
|
|
650
|
+
# Work the CLI took up after the run ended (a finished background task
|
|
651
|
+
# woke it) reopens the run until the next "idle".
|
|
652
|
+
reopen_run
|
|
653
|
+
if state == 'requires_action'
|
|
654
|
+
# The host is answering a request; stdin must outlast it.
|
|
655
|
+
clear_run_end_ceiling
|
|
656
|
+
else
|
|
657
|
+
rearm_run_end_ceiling_between_turns
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# End the run unless a tracked background task is still in flight: such
|
|
662
|
+
# a task may still need hook/SDK-MCP control responses over stdin
|
|
663
|
+
# (Python #1088), and its completion wakes the parent for a follow-up
|
|
664
|
+
# turn whose result (or "idle") ends the run then. A CLI that reports
|
|
665
|
+
# session state never reports "idle" with an agent still live, so this
|
|
666
|
+
# matters for CLIs that report "idle" at every turn end or not at all.
|
|
667
|
+
def maybe_end_run
|
|
668
|
+
end_run if @inflight_tasks.empty?
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# The run is over: wake the stdin-closing waiter. Idempotent.
|
|
672
|
+
def end_run
|
|
673
|
+
clear_run_end_ceiling
|
|
674
|
+
@run_end.end!
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Reopen an ended run for work that started after it ended. A waiter the
|
|
678
|
+
# ended run already woke still closes stdin; this makes a later wait
|
|
679
|
+
# (stream_input's, once its prompts are all written) wait for the new
|
|
680
|
+
# work too. Once stdin is closed, or the reader is gone, the run stays
|
|
681
|
+
# ended.
|
|
682
|
+
def reopen_run
|
|
683
|
+
@run_end = RunEnd.new if @run_end.ended? && !@run_final
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# End the run anyway once the ceiling passes with no new turn. The CLI's
|
|
687
|
+
# own background-wait ceiling only counts once stdin is closed, so without
|
|
688
|
+
# this, work that never finishes would hold "running", and stdin, open
|
|
689
|
+
# forever. It counts only the wait between turns: restarted at each result
|
|
690
|
+
# and whenever the CLI reports "running" again, cleared by main-thread
|
|
691
|
+
# turn activity and by "requires_action", never armed mid-turn.
|
|
692
|
+
#
|
|
693
|
+
# The sleeper is a child of the read task, NOT a #spawn_task entry: it is re-armed at every
|
|
694
|
+
# result and every "running", and @child_tasks never prunes. The read
|
|
695
|
+
# task's stop cascades to it, and every exit path clears it (#end_run in
|
|
696
|
+
# the read loop's ensure, #wait_for_result_and_end_input's ensure), so a
|
|
697
|
+
# pending sleeper can never keep the enclosing reactor alive.
|
|
698
|
+
def arm_run_end_ceiling
|
|
699
|
+
clear_run_end_ceiling
|
|
700
|
+
# A frame read while close is under way (the result branch flushes the
|
|
701
|
+
# mirror first) must not leave a sleeper behind either.
|
|
702
|
+
return if @run_end_ceiling_ms <= 0 || @run_end.ended? || @run_final || @closed ||
|
|
703
|
+
@turn_in_progress || !bidirectional_needs?
|
|
704
|
+
|
|
705
|
+
generation = @run_end_ceiling_generation
|
|
706
|
+
seconds = [@run_end_ceiling_ms, MAX_RUN_END_CEILING_MS].min / 1000.0
|
|
707
|
+
@run_end_ceiling_task = @task.async do
|
|
708
|
+
sleep seconds
|
|
709
|
+
end_run_at_ceiling(generation)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Restart the ceiling if the run is between turns, past a result, with
|
|
714
|
+
# the CLI still reporting work ("running").
|
|
715
|
+
def rearm_run_end_ceiling_between_turns
|
|
716
|
+
return unless @result_received
|
|
717
|
+
return if @session_state.nil? || NON_RUNNING_SESSION_STATES.include?(@session_state)
|
|
718
|
+
|
|
719
|
+
arm_run_end_ceiling
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def end_run_at_ceiling(generation)
|
|
723
|
+
# Cleared or re-armed while this sleeper was already waking up.
|
|
724
|
+
return unless generation == @run_end_ceiling_generation
|
|
725
|
+
|
|
726
|
+
# Detach before ending the run: #end_run clears the ceiling, and
|
|
727
|
+
# clearing it must not stop the task running this very method.
|
|
728
|
+
@run_end_ceiling_task = nil
|
|
729
|
+
# A tracked background agent still running may still need stdin for its
|
|
730
|
+
# hook, permission and SDK MCP requests (Python #1088), so it is not cut
|
|
731
|
+
# off; the ceiling starts over once it settles (#read_messages).
|
|
732
|
+
return unless @inflight_tasks.empty?
|
|
733
|
+
|
|
734
|
+
# A control request the SDK is still answering (a slow hook or SDK MCP
|
|
735
|
+
# tool) must be able to write its reply, so the clock starts over rather
|
|
736
|
+
# than closing stdin under it. Ruby-only guard: Python relies on the CLI
|
|
737
|
+
# reporting requires_action for every such request.
|
|
738
|
+
return arm_run_end_ceiling unless @inflight_control_request_tasks.empty?
|
|
739
|
+
|
|
740
|
+
end_run
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
def clear_run_end_ceiling
|
|
744
|
+
@run_end_ceiling_generation += 1
|
|
745
|
+
task = @run_end_ceiling_task
|
|
746
|
+
@run_end_ceiling_task = nil
|
|
747
|
+
task&.stop
|
|
748
|
+
end
|
|
749
|
+
|
|
503
750
|
# Whether the CLI may still send control requests that need a reply.
|
|
504
751
|
#
|
|
505
752
|
# SDK MCP servers, hooks and the can_use_tool permission callback are all
|
|
@@ -1399,45 +1646,62 @@ module ClaudeAgentSDK
|
|
|
1399
1646
|
})
|
|
1400
1647
|
end
|
|
1401
1648
|
|
|
1402
|
-
# Wait for
|
|
1403
|
-
#
|
|
1404
|
-
#
|
|
1405
|
-
#
|
|
1406
|
-
#
|
|
1407
|
-
#
|
|
1408
|
-
#
|
|
1409
|
-
#
|
|
1410
|
-
#
|
|
1411
|
-
#
|
|
1412
|
-
#
|
|
1413
|
-
#
|
|
1414
|
-
#
|
|
1415
|
-
#
|
|
1649
|
+
# Wait for the end of the run, when hooks, SDK MCP servers or a
|
|
1650
|
+
# can_use_tool callback may still need to exchange control messages with
|
|
1651
|
+
# the CLI, then close stdin. Their replies are all written to stdin, so it
|
|
1652
|
+
# must stay open until the run ends: at the CLI's "idle" session state
|
|
1653
|
+
# after a result, or, from a CLI that reports no session state, at the
|
|
1654
|
+
# first result with no tracked tasks in flight. A result frame ends one
|
|
1655
|
+
# turn, not necessarily the run: background tasks keep running past it,
|
|
1656
|
+
# or have just finished and still wake the parent for a follow-up turn,
|
|
1657
|
+
# and those turns need stdin for control responses (Python #1088, #1190).
|
|
1658
|
+
#
|
|
1659
|
+
# No timeout bounds a turn (closing stdin mid-turn silently broke
|
|
1660
|
+
# hooks/MCP on turns longer than the old 60s bound; Python c3d96cb). The
|
|
1661
|
+
# wait BETWEEN turns is bounded: if the CLI still reports "running"
|
|
1662
|
+
# CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (10 minutes by default, 0 for no
|
|
1663
|
+
# limit) after a result with no new turn, the run ends anyway
|
|
1664
|
+
# (#arm_run_end_ceiling). A turn under way, a request the SDK is still
|
|
1665
|
+
# answering and a tracked background agent still in flight (Python #1088)
|
|
1666
|
+
# stop that clock. The run is guaranteed to end: as above, or in
|
|
1667
|
+
# read_messages' ensure when the process exits early.
|
|
1416
1668
|
#
|
|
1417
|
-
# Known limitation (same as Python's):
|
|
1418
|
-
#
|
|
1419
|
-
#
|
|
1420
|
-
#
|
|
1421
|
-
#
|
|
1422
|
-
# prompts — the common one-shot shapes — are fully covered.
|
|
1669
|
+
# Known limitation (same as Python's): from a CLI that reports no session
|
|
1670
|
+
# state, a result for an earlier message of an Enumerator prompt still
|
|
1671
|
+
# ends the run even when a later message is already queued CLI-side, so
|
|
1672
|
+
# control requests from that later turn can find stdin closed.
|
|
1673
|
+
# Single-message and String prompts are fully covered.
|
|
1423
1674
|
def wait_for_result_and_end_input
|
|
1424
|
-
@
|
|
1675
|
+
@run_end.wait if bidirectional_needs?
|
|
1425
1676
|
ensure
|
|
1677
|
+
@run_final = true
|
|
1678
|
+
clear_run_end_ceiling
|
|
1426
1679
|
@transport.end_input
|
|
1427
1680
|
end
|
|
1428
1681
|
|
|
1429
|
-
# Stream input messages to transport
|
|
1430
|
-
#
|
|
1431
|
-
#
|
|
1432
|
-
#
|
|
1433
|
-
#
|
|
1682
|
+
# Stream input messages to transport, then close stdin once the run ends
|
|
1683
|
+
# (#wait_for_result_and_end_input). Each message written owes a run of its
|
|
1684
|
+
# own, so the wait is for the last message's run, not an earlier one's.
|
|
1685
|
+
#
|
|
1686
|
+
# NOTE: iteration runs on the reactor (the deliberate FiberBoundary
|
|
1687
|
+
# carve-out — see fiber_boundary.rb): scheduler-aware blocking
|
|
1688
|
+
# (Thread::Queue#pop, sleep, socket IO) parks only this task; CPU-bound or
|
|
1689
|
+
# scheduler-opaque work in the enumerator must be moved to a producer
|
|
1690
|
+
# Thread by the user.
|
|
1434
1691
|
def stream_input(stream)
|
|
1435
1692
|
wrote_message = false
|
|
1436
1693
|
stream.each do |message|
|
|
1437
1694
|
break if @closed
|
|
1438
1695
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1696
|
+
# Serialized first: a message verbatim_prompts cannot mark raises
|
|
1697
|
+
# here, before the run is reopened for a message that never went out.
|
|
1698
|
+
line = Query.serialize_user_message(message, @verbatim_prompts)
|
|
1699
|
+
# This message owes a run of its own, result included: an earlier
|
|
1700
|
+
# one having ended does not end it.
|
|
1701
|
+
reopen_run
|
|
1702
|
+
@result_received = false
|
|
1703
|
+
clear_run_end_ceiling
|
|
1704
|
+
writeln(line)
|
|
1441
1705
|
wrote_message = true
|
|
1442
1706
|
end
|
|
1443
1707
|
rescue StandardError => e
|
|
@@ -1446,13 +1710,12 @@ module ClaudeAgentSDK
|
|
|
1446
1710
|
ensure
|
|
1447
1711
|
# Three teardown shapes:
|
|
1448
1712
|
# - #close in progress (@closed, Async::Stop unwinding): do nothing —
|
|
1449
|
-
# the transport is about to be closed, and waiting on
|
|
1450
|
-
#
|
|
1451
|
-
#
|
|
1713
|
+
# the transport is about to be closed, and waiting on the run's end
|
|
1714
|
+
# inside a stopping fiber could suspend teardown. Mirrors Python,
|
|
1715
|
+
# where cancellation skips this entirely.
|
|
1452
1716
|
# - A turn is in flight (some message reached the CLI): hold stdin
|
|
1453
|
-
# open until
|
|
1454
|
-
#
|
|
1455
|
-
# guaranteed to signal).
|
|
1717
|
+
# open until the run ends so hooks/SDK MCP control replies can still
|
|
1718
|
+
# be written (the run's end or process exit is guaranteed to signal).
|
|
1456
1719
|
# - No complete message ever reached the CLI (empty stream, or the
|
|
1457
1720
|
# stream raised before the first write): no result can ever arrive,
|
|
1458
1721
|
# so waiting would park query() forever beside an idle CLI. Close
|
|
@@ -1462,6 +1725,7 @@ module ClaudeAgentSDK
|
|
|
1462
1725
|
if wrote_message
|
|
1463
1726
|
wait_for_result_and_end_input
|
|
1464
1727
|
else
|
|
1728
|
+
@run_final = true
|
|
1465
1729
|
@transport.end_input
|
|
1466
1730
|
end
|
|
1467
1731
|
end
|
|
@@ -16,6 +16,15 @@ module ClaudeAgentSDK
|
|
|
16
16
|
DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
|
|
17
17
|
# @api private
|
|
18
18
|
MINIMUM_CLAUDE_CODE_VERSION = '2.0.0'
|
|
19
|
+
# First Claude Code version that honors `client_composed` on user
|
|
20
|
+
# messages, which ClaudeAgentOptions#verbatim_prompts relies on.
|
|
21
|
+
# @api private
|
|
22
|
+
VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION = '2.1.248'
|
|
23
|
+
# Asks the CLI for session_state_changed frames marked sdk_host_only,
|
|
24
|
+
# which Query reads to tell when the run is over and keeps out of the
|
|
25
|
+
# caller's stream. CLIs that predate it send no frames.
|
|
26
|
+
# @api private
|
|
27
|
+
SDK_READS_SESSION_STATE_ENV_VAR = 'CLAUDE_CODE_SDK_READS_SESSION_STATE'
|
|
19
28
|
# @api private
|
|
20
29
|
SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'
|
|
21
30
|
# @api private
|
|
@@ -288,6 +297,15 @@ module ClaudeAgentSDK
|
|
|
288
297
|
# under the caller's distributed trace (Python SDK #821 parity). No-op
|
|
289
298
|
# when opentelemetry is not loaded or there is no active span.
|
|
290
299
|
inject_otel_trace_context(process_env, custom_env)
|
|
300
|
+
# Query waits for the CLI's session_state_changed "idle" before closing
|
|
301
|
+
# stdin on a run that serves control requests (Python #1279). Ask for
|
|
302
|
+
# the frames it drops (sdk_host_only) unless the caller named the
|
|
303
|
+
# variable, in any case, in options.env (a nil value unsets it) or the
|
|
304
|
+
# inherited environment. CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS stays the
|
|
305
|
+
# caller's own opt-in to seeing the frames; the SDK never sets it.
|
|
306
|
+
unless process_env.keys.any? { |key| key.casecmp?(SDK_READS_SESSION_STATE_ENV_VAR) }
|
|
307
|
+
process_env[SDK_READS_SESSION_STATE_ENV_VAR] = '1'
|
|
308
|
+
end
|
|
291
309
|
process_env['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'] = 'true' if @options.enable_file_checkpointing
|
|
292
310
|
process_env['PWD'] = @cwd.to_s if @cwd
|
|
293
311
|
|
|
@@ -922,6 +940,13 @@ module ClaudeAgentSDK
|
|
|
922
940
|
'Some features may not work correctly.'
|
|
923
941
|
warn warning
|
|
924
942
|
end
|
|
943
|
+
|
|
944
|
+
verbatim_min_parts = VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION.split('.').map(&:to_i)
|
|
945
|
+
if @options.verbatim_prompts? && (version_parts <=> verbatim_min_parts).negative?
|
|
946
|
+
warn "Warning: verbatim_prompts is enabled, but Claude Code version #{version} at #{@cli_path} " \
|
|
947
|
+
'ignores it: prompts will still have @path mentions expanded and slash commands dispatched. ' \
|
|
948
|
+
"Claude Code #{VERBATIM_PROMPTS_MINIMUM_CLAUDE_CODE_VERSION} or later is required."
|
|
949
|
+
end
|
|
925
950
|
end
|
|
926
951
|
rescue StandardError
|
|
927
952
|
# Ignore version check errors — including Timeout::Error from the
|
|
@@ -192,7 +192,13 @@ module ClaudeAgentSDK
|
|
|
192
192
|
:outcome # "success", "error", or "cancelled"
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
-
# Session state changed system message
|
|
195
|
+
# Session state changed system message.
|
|
196
|
+
#
|
|
197
|
+
# Reaches your code only when you opt in with
|
|
198
|
+
# `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` in ClaudeAgentOptions#env. The
|
|
199
|
+
# SDK also asks the CLI for these frames on its own behalf (to tell when a
|
|
200
|
+
# query() run is over), but those arrive marked `sdk_host_only` and are
|
|
201
|
+
# dropped before the message stream.
|
|
196
202
|
class SessionStateChangedMessage < SystemMessage
|
|
197
203
|
attr_accessor :uuid, :session_id,
|
|
198
204
|
:state # "idle", "running", or "requires_action"
|
|
@@ -81,21 +81,11 @@ module ClaudeAgentSDK
|
|
|
81
81
|
self.include_hook_events = false
|
|
82
82
|
self.strict_mcp_config = false
|
|
83
83
|
self.forward_subagent_text = false
|
|
84
|
+
self.verbatim_prompts = false
|
|
84
85
|
|
|
85
86
|
super(merge_with_defaults(attributes || {}))
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
self.env ||= {}
|
|
89
|
-
self.extra_args ||= {}
|
|
90
|
-
self.mcp_servers ||= {}
|
|
91
|
-
self.add_dirs ||= []
|
|
92
|
-
self.observers ||= []
|
|
93
|
-
self.allowed_tools ||= []
|
|
94
|
-
self.disallowed_tools ||= []
|
|
95
|
-
self.session_store_flush ||= 'batched'
|
|
96
|
-
# 0 is a valid (immediate) timeout, so only fill in the default for nil.
|
|
97
|
-
self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
|
|
98
|
-
self.callback_scheduling = :thread if callback_scheduling.nil?
|
|
88
|
+
fill_nil_defaults
|
|
99
89
|
end
|
|
100
90
|
|
|
101
91
|
def dup_with(**changes)
|
|
@@ -203,6 +193,58 @@ module ClaudeAgentSDK
|
|
|
203
193
|
@forward_subagent_text = coerce_boolean(value)
|
|
204
194
|
end
|
|
205
195
|
|
|
196
|
+
# Deliver every prompt to Claude as written.
|
|
197
|
+
#
|
|
198
|
+
# When true, every user message the SDK sends is marked `client_composed`:
|
|
199
|
+
# a String prompt to {ClaudeAgentSDK.query}, {Client#connect} or
|
|
200
|
+
# {Client#query}, and every message of a streamed (Enumerable) prompt to
|
|
201
|
+
# any of them. Claude Code then delivers the text exactly as given: no
|
|
202
|
+
# `@path` file-mention expansion and no slash-command dispatch. Use it when
|
|
203
|
+
# the prompt is assembled from content your end user did not type (earlier
|
|
204
|
+
# turns, tool output, third-party text), so an `@/absolute/path` inside it
|
|
205
|
+
# cannot make Claude Code read a local file. `tools: []`, `allowed_tools`
|
|
206
|
+
# and `disallowed_tools` do not stop that expansion: it happens before the
|
|
207
|
+
# model runs, without a tool call.
|
|
208
|
+
#
|
|
209
|
+
# While the option is on there is no per-message opt-out: a
|
|
210
|
+
# `client_composed` key on a streamed message Hash (either spelling) is
|
|
211
|
+
# overwritten. For per-turn control, leave the option off and set
|
|
212
|
+
# `client_composed: true` on individual streamed messages. The caller's
|
|
213
|
+
# Hashes are never mutated. A streamed JSONL String is parsed, marked and
|
|
214
|
+
# re-serialized; one that is not a single JSON object raises
|
|
215
|
+
# `ArgumentError` rather than being sent unmarked (on the background
|
|
216
|
+
# streaming paths that ends the stream with a warning, like any other
|
|
217
|
+
# stream error).
|
|
218
|
+
#
|
|
219
|
+
# On current Claude Code versions a turn delivered this way also skips the
|
|
220
|
+
# turn-start attachment pass as a whole: `@server:resource` MCP mentions
|
|
221
|
+
# are not expanded either, and the prompt goes without the context Claude
|
|
222
|
+
# Code normally attaches (nested `CLAUDE.md` and rules files, skill and
|
|
223
|
+
# tool listings, other per-turn reminders). The pass between tool calls is
|
|
224
|
+
# unaffected, so most of that context arrives after the turn's first tool
|
|
225
|
+
# call instead.
|
|
226
|
+
#
|
|
227
|
+
# Requires Claude Code 2.1.248 or later; older versions ignore the field,
|
|
228
|
+
# so prompts are still expanded there, and the SDK warns when it connects
|
|
229
|
+
# to one with this option on. Read once when the session starts. Not a
|
|
230
|
+
# CLI flag. Matches the Python SDK's `verbatim_prompts`.
|
|
231
|
+
#
|
|
232
|
+
# Assigning coerces to a Boolean; {#verbatim_prompts?} is the predicate
|
|
233
|
+
# form.
|
|
234
|
+
#
|
|
235
|
+
# @return [Boolean]
|
|
236
|
+
attr_reader :verbatim_prompts
|
|
237
|
+
|
|
238
|
+
# @return [Boolean] {#verbatim_prompts}, as a strict Boolean.
|
|
239
|
+
def verbatim_prompts?
|
|
240
|
+
!!verbatim_prompts
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# @see #verbatim_prompts
|
|
244
|
+
def verbatim_prompts=(value)
|
|
245
|
+
@verbatim_prompts = coerce_boolean(value)
|
|
246
|
+
end
|
|
247
|
+
|
|
206
248
|
# Request model-generated progress summaries for subagent (`local_agent`)
|
|
207
249
|
# tasks. `true` *requests* generation: while the CLI has it enabled, a
|
|
208
250
|
# subagent's {TaskProgressMessage#summary} **may** carry a one-line status.
|
|
@@ -288,6 +330,21 @@ module ClaudeAgentSDK
|
|
|
288
330
|
|
|
289
331
|
private
|
|
290
332
|
|
|
333
|
+
# Non-nil defaults for options that need them.
|
|
334
|
+
def fill_nil_defaults
|
|
335
|
+
self.env ||= {}
|
|
336
|
+
self.extra_args ||= {}
|
|
337
|
+
self.mcp_servers ||= {}
|
|
338
|
+
self.add_dirs ||= []
|
|
339
|
+
self.observers ||= []
|
|
340
|
+
self.allowed_tools ||= []
|
|
341
|
+
self.disallowed_tools ||= []
|
|
342
|
+
self.session_store_flush ||= 'batched'
|
|
343
|
+
# 0 is a valid (immediate) timeout, so only fill in the default for nil.
|
|
344
|
+
self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
|
|
345
|
+
self.callback_scheduling = :thread if callback_scheduling.nil?
|
|
346
|
+
end
|
|
347
|
+
|
|
291
348
|
# Strict key validation: unlike other Type subclasses (which silently drop
|
|
292
349
|
# unknown keys for forward-compat with newer CLI output), ClaudeAgentOptions
|
|
293
350
|
# is a developer-facing config object — typos should fail loudly.
|
data/lib/claude_agent_sdk.rb
CHANGED
|
@@ -749,7 +749,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
|
|
|
749
749
|
forward_subagent_text: configured_options.forward_subagent_text?,
|
|
750
750
|
agent_progress_summaries: configured_options.agent_progress_summaries,
|
|
751
751
|
callback_scheduling: callback_scheduling,
|
|
752
|
-
callback_wrapper: callback_wrapper
|
|
752
|
+
callback_wrapper: callback_wrapper,
|
|
753
|
+
verbatim_prompts: configured_options.verbatim_prompts?,
|
|
754
|
+
run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
|
|
753
755
|
)
|
|
754
756
|
|
|
755
757
|
# Mirror transcripts to the session_store, if configured. Installed
|
|
@@ -782,7 +784,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
|
|
|
782
784
|
parent_tool_use_id: nil,
|
|
783
785
|
session_id: ''
|
|
784
786
|
}
|
|
785
|
-
transport.write("#{
|
|
787
|
+
transport.write("#{Query.serialize_user_message(message, configured_options.verbatim_prompts?)}\n")
|
|
786
788
|
# Background-spawn so messages stream to the user block while stdin
|
|
787
789
|
# close waits (without timeout) for the first result; a synchronous
|
|
788
790
|
# call would defer all delivery until the turn completes (mirrors
|
|
@@ -1084,7 +1086,7 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
|
|
|
1084
1086
|
parent_tool_use_id: nil,
|
|
1085
1087
|
session_id: session_id
|
|
1086
1088
|
}
|
|
1087
|
-
writeln(
|
|
1089
|
+
writeln(Query.serialize_user_message(message, @verbatim_prompts))
|
|
1088
1090
|
elsif prompt.respond_to?(:each)
|
|
1089
1091
|
# Inline iteration on the caller, Python client.py parity — NOT
|
|
1090
1092
|
# Query#stream_input, whose ensure always ends input after
|
|
@@ -1394,6 +1396,10 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
|
|
|
1394
1396
|
exclude_dynamic_sections = ClaudeAgentSDK.extract_exclude_dynamic_sections(configured_options.system_prompt)
|
|
1395
1397
|
system_prompt_snapshot = ClaudeAgentSDK.extract_system_prompt_snapshot(configured_options.system_prompt)
|
|
1396
1398
|
|
|
1399
|
+
# Captured once, so String and streamed prompts in one session are
|
|
1400
|
+
# stamped alike (the Query stamps the streamed ones with this value).
|
|
1401
|
+
@verbatim_prompts = configured_options.verbatim_prompts?
|
|
1402
|
+
|
|
1397
1403
|
# Create Query handler
|
|
1398
1404
|
@query_handler = Query.new(
|
|
1399
1405
|
transport: @transport,
|
|
@@ -1408,7 +1414,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
|
|
|
1408
1414
|
forward_subagent_text: configured_options.forward_subagent_text?,
|
|
1409
1415
|
agent_progress_summaries: configured_options.agent_progress_summaries,
|
|
1410
1416
|
callback_scheduling: @callback_scheduling,
|
|
1411
|
-
callback_wrapper: @callback_wrapper
|
|
1417
|
+
callback_wrapper: @callback_wrapper,
|
|
1418
|
+
verbatim_prompts: @verbatim_prompts,
|
|
1419
|
+
run_end_ceiling_ms: Query.run_end_ceiling_ms(configured_options.env)
|
|
1412
1420
|
)
|
|
1413
1421
|
|
|
1414
1422
|
# Mirror transcripts to the session_store, if configured.
|
|
@@ -1450,7 +1458,9 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
|
|
|
1450
1458
|
# key styles — an explicit nil is preserved, mirroring Python's
|
|
1451
1459
|
# `"session_id" not in msg`). Strings pass through verbatim (Ruby
|
|
1452
1460
|
# superset: Streaming.user_message emits pre-serialized JSONL; no
|
|
1453
|
-
# parse-stamp-regenerate, which would block the reactor on huge frames)
|
|
1461
|
+
# parse-stamp-regenerate, which would block the reactor on huge frames),
|
|
1462
|
+
# except that verbatim_prompts must mark them `client_composed`, so with
|
|
1463
|
+
# that option on they are parsed and re-serialized (Query.stamp_user_message).
|
|
1454
1464
|
def stream_query_messages(prompt, session_id)
|
|
1455
1465
|
prompt.each do |msg|
|
|
1456
1466
|
case msg
|
|
@@ -1460,13 +1470,13 @@ module ClaudeAgentSDK # rubocop:disable Metrics/ModuleLength -- the public entry
|
|
|
1460
1470
|
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1461
1471
|
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1462
1472
|
end
|
|
1463
|
-
writeln(
|
|
1473
|
+
writeln(Query.serialize_user_message(msg, @verbatim_prompts))
|
|
1464
1474
|
when String
|
|
1465
1475
|
if (text = ClaudeAgentSDK.extract_user_prompt_text(msg))
|
|
1466
1476
|
ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, text,
|
|
1467
1477
|
scheduling: @callback_scheduling, wrapper: @callback_wrapper)
|
|
1468
1478
|
end
|
|
1469
|
-
writeln(msg)
|
|
1479
|
+
writeln(Query.serialize_user_message(msg, @verbatim_prompts))
|
|
1470
1480
|
else
|
|
1471
1481
|
# No to_s fallback — silently serializing arbitrary objects is the
|
|
1472
1482
|
# exact inspect-garbage bug class this method exists to prevent.
|
|
@@ -85,6 +85,7 @@ module ClaudeAgentSDK
|
|
|
85
85
|
?include_hook_events: bool?,
|
|
86
86
|
?strict_mcp_config: bool?,
|
|
87
87
|
?forward_subagent_text: bool?,
|
|
88
|
+
?verbatim_prompts: bool?,
|
|
88
89
|
?agent_progress_summaries: bool?,
|
|
89
90
|
?callback_scheduling: (:thread | :inline | "thread" | "inline")?,
|
|
90
91
|
?callback_wrapper: _CallbackWrapper?
|
|
@@ -264,6 +265,14 @@ module ClaudeAgentSDK
|
|
|
264
265
|
|
|
265
266
|
def forward_subagent_text?: () -> bool
|
|
266
267
|
|
|
268
|
+
# Mark every outgoing user message client_composed, so the CLI delivers
|
|
269
|
+
# it as written (false by default).
|
|
270
|
+
attr_reader verbatim_prompts: bool?
|
|
271
|
+
|
|
272
|
+
def verbatim_prompts=: (untyped value) -> bool?
|
|
273
|
+
|
|
274
|
+
def verbatim_prompts?: () -> bool
|
|
275
|
+
|
|
267
276
|
# Request model-generated progress summaries for subagent tasks (nil:
|
|
268
277
|
# unset, not sent).
|
|
269
278
|
attr_reader agent_progress_summaries: bool?
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: claude-agent-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ya-luotao
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|