claude-agent-sdk 0.32.0 → 0.33.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04f9cdf2c66d67b2af3a2a8c8dd14ee7fd418ccc7a7b4be727f73d70d02a8446
4
- data.tar.gz: 7d8990d6720a47d104c59cc03d2288feefc55854d10badc19bdbc1d42a6dca04
3
+ metadata.gz: 6994a2674fc098001857cff72a21c3d804744c6fa25cb866f2a133ee1a2ec876
4
+ data.tar.gz: 349b7ce2314eac4c315e3c3d03ef0e466926fe3abb0f8823d1780f1486a8ce0d
5
5
  SHA512:
6
- metadata.gz: d441ee7bfe0f1bb98a1ea2088bb908601ae16b545cd8e05928addb0966d746ae6c0de137b48bc7e3949d8e040d1eec58551e5775886a3658d6f9e9265be54b05
7
- data.tar.gz: 1ef545a581c38233903ef7da689fcdc93776d6a764c5b1754f1ea77adbd775275c67eb817a4494ca83f8768218ea281bed96507f429c7880380ceb995723b197
6
+ metadata.gz: 0442feb3be095e8be7903e55e51f5620607b682cb0ed57f1ded989f01f6bf04bf22cee36556b52fd8cb4072d2e3bf4a2400c459b8cad231a83590d75b1010403
7
+ data.tar.gz: e287cd5825a5e16f1c8d7ce8295d0f3fba07ee15c5e4502be2dbfefcd223cf061c6e20afe5e5e6349e7ee2729c8c70e77aea152c6b95dc997978bc5ef358f68e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.33.1] - 2026-09-21
11
+
12
+ Compatibility with `json` 3.x and `mcp` 1.x. Upgrade if your bundle resolves `json` 3.x — `rename_session` / `tag_session` raise on 0.33.0.
13
+
14
+ ### Changed
15
+ - `mcp` dependency is now `>= 0.20, < 2` (was `>= 0.6, < 1`). The floor moves to 0.20 because `mcp` 0.19 and older validate through the `json-schema` gem, which breaks under `json` 3.x and fails every SDK MCP `tools/call`; with `json` 2.x those versions still pass, so this only forces an `mcp` upgrade on bundles pinned below 0.20. The suite passes against every 1.x release through 1.6.0, and `initialize` / `tools/list` / `tools/call` / `resources/*` / `prompts/*` wire output is byte-identical to 0.2x.
16
+ - SDK MCP tool handler exceptions now reach the model as the bare exception message (matching Python's `str(e)` and `SdkMcpServer#call_tool`) instead of the gem's `Internal error calling tool X: msg`. The exception is rescued inside the SDK's tool class, so the text no longer depends on the `mcp` gem version — `mcp` 1.2+ redacts the message from its own wrapper, which would otherwise have left the model with no error text to self-correct from. Still in-band `isError: true`.
17
+
18
+ ### Fixed
19
+ - `rename_session` / `tag_session` raised `ArgumentError: unknown keyword: space_size` under `json` 3.x, which takes generator options as strict keywords. The option was a no-op on `json` 2.x (output unchanged), so it is simply gone. A fresh end-user bundle resolves `json` 3.x through `async → console → json`; CI missed it only because the dev-only RuboCop pin holds `json` at 2.x.
20
+
21
+ ## [0.33.0] - 2026-09-21
22
+
23
+ Subagent capabilities for UI builders: metadata reads, background snapshots, cooperative callback cancellation, and the task/background/permission signals the CLI already emits — all raw data and controls, no status model. Ruby-ahead of the Python SDK (0.2.153).
24
+
25
+ ### Added
26
+ - `get_subagent_metadata` / `get_subagent_metadata_from_store`: read optional subagent metadata with original string keys, including type, spawning tool ID, parent agent, depth, and future CLI fields. Disk reads reuse transcript scoping without parsing the transcript; store reads select the latest metadata entry even before messages arrive. These are historical reads, not live-status queries.
27
+ - `background_tasks` and `session_crons` on `StopHookInput` / `SubagentStopHookInput`. Raw snapshots preserve unavailable (`nil`) versus explicitly empty (`[]`) and describe the parent session, not all foreground/background agents.
28
+ - Cooperative permission cancellation through `ToolPermissionContext#signal` (`CancellationSignal#cancelled?` / `#wait`) and the associated `request_id`. CLI cancellation, disconnect, EOF, and failed dispatch invalidate pending requests, including callbacks running on worker threads; normal decisions do not. User threads are not forcibly stopped, and late decisions after observed cancellation cannot become allow responses.
29
+ - `HookContext#signal` / `#request_id` use the same per-invocation cancellation contract, including hook timeouts. Thread callbacks can cooperate with cancellation; late hook output is discarded.
30
+ - A minimal subagent event subscription example and capability reference covering lifecycle events, metadata, background snapshots, and permission cancellation. UI and application status aggregation remain outside the SDK.
31
+ - Opt-in real-CLI subagent contract tests for ID/metadata/text correlation, permission cancellation on interrupt, and background completion/stop after a parent result. They self-skip without CLI credentials.
32
+ - Subagent UI signals the CLI already emits, read from the schema embedded in Claude Code CLI 2.1.278 (the Python SDK has none of these as of Python SDK 0.2.153; only partly verified live — a smoke run against CLI 2.1.278 confirmed the `task_started` fields and the targeted-miss `background_tasks` response; the rest is schema-derived). The SDK exposes raw data and controls only — no status aggregation.
33
+ - `TaskStartedMessage#subagent_type` / `#is_backgrounded` / `#spawn_depth`, `TaskProgressMessage#subagent_type`, `TaskNotificationMessage#reason` / `#resource_links` (raw Array, symbol keys with the wire spelling), the `#skip_transcript` / `#ambient` display flags on both `TaskStartedMessage` and `TaskNotificationMessage` (hints for the host — the SDK never filters frames or computes activity), and `TaskUpdatedMessage#is_backgrounded` / `#error` / `#end_time` / `#total_paused_ms` / `#description` derived from `patch` like `status`. `is_backgrounded` keeps `nil` (not reported) distinct from an explicit `false` (foreground, spawning tool call blocking). `TaskUpdatedMessage` now also reads a string-keyed `patch` on hand-built messages.
34
+ - `BackgroundTasksChangedMessage` (`background_tasks_changed`): the full set of live background tasks, a level signal with REPLACE semantics. The SDK's own stdin-close bookkeeping still deliberately ignores this frame.
35
+ - `PermissionDeniedMessage` (`permission_denied`): a tool call auto-denied without an interactive prompt, with `agent_id` for subagent routing (not a permission `request_id`). A best-effort advisory, not a complete denial feed — `ResultMessage#permission_denials` stays authoritative.
36
+ - `Client#background_tasks(tool_use_id: nil)` / `Query#background_tasks`: send in-flight foreground tasks to the background (Ctrl+B). Keyed by the spawning `tool_use_id`, not `task_id` / `agent_id`. The targeted form returns `{ backgrounded: true }` or `{ backgrounded: false }` — a definitive miss, after which no event is coming; `nil` is the explicit all-tasks form and returns `{}`. Because the CLI treats `''` as "all tasks", anything other than `nil` or a non-empty String raises `ArgumentError` before a request is written. `TaskUpdatedMessage#is_backgrounded` / `BackgroundTasksChangedMessage` report the lifecycle state that follows.
37
+ - `ClaudeAgentOptions#agent_progress_summaries`: request model-generated progress summaries for subagents; `TaskProgressMessage#summary` may then be present, and stays optional. Tri-state; `nil` omits `agentProgressSummaries` from the `initialize` request and `true` / `false` are forwarded verbatim. An enable switch, not a live toggle: CLI 2.1.278 only acts on a truthy value, so `false` is equivalent to unset.
38
+ - **Compatibility:** `background_tasks_changed` and `permission_denied` frames previously parsed as a generic `SystemMessage`. Both new classes subclass it, so `when SystemMessage` and `#data` consumers are unaffected; code matching on `message.class == SystemMessage` will no longer see them.
39
+
40
+ ### Fixed
41
+ - Best-effort control error/cancellation replies no longer leak a secondary connection error when the CLI has already exited. Original transport read errors still propagate to the consumer.
42
+ - Control-request tracking is identity-guarded: if the CLI ever reused an in-flight request ID, the first handler finishing no longer untracks the later request's cancellation signal or task, so EOF, disconnect, and `control_cancel_request` still invalidate it and its late decision cannot be sent as a success.
43
+ - `ClaudeAgentSDK.configure` defaults now merge by option, not by literal key spelling. `ClaudeAgentOptions` accepts symbol/string and snake_case/camelCase names, but the defaults merge compared raw keys, so a differently spelled per-call key rode along as a second entry: `'permissionMode' => nil` wiped a configured `permission_mode:` instead of inheriting it, and a Hash option such as `'env' => {...}` replaced the configured `env:` instead of merging into it. Unknown option names are still reported with the caller's own spelling.
44
+
10
45
  ## [0.32.0] - 2026-09-17
11
46
 
12
47
  Syncs the gem with Python SDK **0.2.153** (previously 0.2.147). The intervening Python releases 0.2.148–0.2.152 only bump the CLI binary Python bundles; this gem does not vendor a CLI, so they carry no Ruby-side change.
data/README.md CHANGED
@@ -27,7 +27,7 @@ A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-ove
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem 'claude-agent-sdk', '~> 0.32.0'
30
+ gem 'claude-agent-sdk', '~> 0.33.1'
31
31
  ```
32
32
 
33
33
  Then `bundle install`, or install directly with `gem install claude-agent-sdk`. To track unreleased changes, point the Gemfile at GitHub: `gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'`.
@@ -146,6 +146,7 @@ See [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) for the full
146
146
  | All hook events, typed inputs, permission callbacks | [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) |
147
147
  | Structured output, thinking, budget, fallback and advisor models, sandbox, bare mode, checkpointing | [docs/configuration.md](docs/configuration.md) |
148
148
  | Session listing, reading, renaming, tagging, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) |
149
+ | Subagent capabilities, event contracts, and minimal example | [docs/subagents.md](docs/subagents.md) |
149
150
  | OpenTelemetry tracing, Langfuse, custom observers | [docs/observability.md](docs/observability.md) |
150
151
  | Rails: fiber safety, solid_queue fiber workers, ActionCable, jobs, initializer | [docs/rails.md](docs/rails.md) |
151
152
  | Vendoring a pinned CLI binary and CLI discovery order | [docs/cli-installer.md](docs/cli-installer.md) |
@@ -180,7 +181,7 @@ All three SDKs drive the same CLI over the same protocol, so capabilities line u
180
181
  | Hooks (all 27 events) | ✅ | ✅ | ✅ |
181
182
  | Permission callbacks | ✅ | ✅ | ✅ |
182
183
  | Structured output | ✅ | ✅ | ✅ |
183
- | All 25 message types | ✅ | partial | ✅ |
184
+ | All 28 message types | ✅ | partial | ✅ |
184
185
  | [Sandbox](https://github.com/anthropic-experimental/sandbox-runtime) settings | ✅ | partial | ✅ |
185
186
  | Bare mode (`--bare`) | ✅ | ✅ | ✅ |
186
187
  | File checkpointing & rewind | ✅ | ✅ | ✅ |
data/docs/client.md CHANGED
@@ -44,6 +44,10 @@ Async do
44
44
  client.reconnect_mcp_server('my-server') # Reconnect a failed MCP server
45
45
  client.toggle_mcp_server('my-server', false) # Enable/disable an MCP server
46
46
  client.stop_task('task_abc123') # Stop a running background task
47
+ client.background_tasks # Background every foreground task (Ctrl+B) => {}
48
+ client.background_tasks(tool_use_id: 'toolu_01') # Only the task spawned by that tool_use block
49
+ # => { backgrounded: true } | { backgrounded: false } (definitive miss)
50
+ # '' or a non-String raises ArgumentError; nil is the all-tasks form
47
51
 
48
52
  client.disconnect
49
53
  end.wait
@@ -262,6 +262,39 @@ Matches the TypeScript SDK's `forwardSubagentText`. The capability is
262
262
  negotiated on the control-protocol handshake, so it applies to both
263
263
  `ClaudeAgentSDK.query` and `Client`.
264
264
 
265
+ ## Subagent Progress Summaries
266
+
267
+ Set `agent_progress_summaries` to **request** model-generated one-line progress
268
+ summaries for subagent (`local_agent`) tasks. While the CLI has generation
269
+ enabled, a subagent's `TaskProgressMessage#summary` **may** be present; the
270
+ field stays optional on the wire, so not every progress frame carries one —
271
+ read it nil-safely:
272
+
273
+ ```ruby
274
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(agent_progress_summaries: true)
275
+
276
+ # ...
277
+ when ClaudeAgentSDK::TaskProgressMessage
278
+ puts "#{message.task_id}: #{message.summary}" if message.summary
279
+ ```
280
+
281
+ `false` and `nil` do not enable generation. They do not promise suppression
282
+ either: a process that already enabled summaries keeps producing them, and a
283
+ backgrounded `mcp_task` reports its own status in `summary` regardless of this
284
+ option.
285
+
286
+ The option is tri-state: `nil` (the default) omits the key from the `initialize`
287
+ control request, while `true` and `false` are forwarded verbatim as
288
+ `agentProgressSummaries`. It is an **enable switch, not a live toggle**: CLI
289
+ 2.1.278 only acts on a truthy value, so `false` is schema-valid but equivalent
290
+ to leaving the option unset — it does not switch summaries off on a process that
291
+ already enabled them. With `ClaudeAgentSDK.configure` defaults, a per-call
292
+ `false` overrides a global `true`, and an unset per-call value inherits the
293
+ global one. It applies to both `ClaudeAgentSDK.query` and `Client`. The key was
294
+ read from the schema embedded in Claude Code CLI 2.1.278 and has not been
295
+ verified against a live run. See
296
+ [subagent capabilities](subagents.md).
297
+
265
298
  ## File Checkpointing & Rewind
266
299
 
267
300
  Enable file checkpointing to revert file changes to a previous state:
@@ -12,8 +12,8 @@ All hook input objects include common fields like `session_id`, `transcript_path
12
12
  - `PostToolUse` → `PostToolUseHookInput` (`tool_name`, `tool_input`, `tool_response`, `tool_use_id`)
13
13
  - `PostToolUseFailure` → `PostToolUseFailureHookInput` (`tool_name`, `tool_input`, `tool_use_id`, `error`, `is_interrupt`)
14
14
  - `UserPromptSubmit` → `UserPromptSubmitHookInput` (`prompt`)
15
- - `Stop` → `StopHookInput` (`stop_hook_active`)
16
- - `SubagentStop` → `SubagentStopHookInput` (`stop_hook_active`, `agent_id`, `agent_transcript_path`, `agent_type`)
15
+ - `Stop` → `StopHookInput` (`stop_hook_active`, `last_assistant_message`, `background_tasks`, `session_crons`)
16
+ - `SubagentStop` → `SubagentStopHookInput` (`stop_hook_active`, `agent_id`, `agent_transcript_path`, `agent_type`, `last_assistant_message`, `background_tasks`, `session_crons`)
17
17
  - `PreCompact` → `PreCompactHookInput` (`trigger`, `custom_instructions`)
18
18
  - `Notification` → `NotificationHookInput` (`message`, `title`, `notification_type`)
19
19
  - `SubagentStart` → `SubagentStartHookInput` (`agent_id`, `agent_type`)
@@ -21,6 +21,14 @@ All hook input objects include common fields like `session_id`, `transcript_path
21
21
 
22
22
  All 27 hook events have typed input classes. See [`ClaudeAgentSDK::HOOK_EVENTS`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types.rb) and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb).
23
23
 
24
+ `background_tasks` and `session_crons` are optional arrays of raw CLI hashes.
25
+ `nil` means the CLI did not provide a snapshot; `[]` means it provided an empty
26
+ one. Nested keys are passed through unchanged (Symbols on the live transport).
27
+ Both snapshots belong to the **parent session**, even on `SubagentStop`; they
28
+ are not a complete list of foreground and background agents. A task disappearing
29
+ from a background snapshot does not establish its terminal status, and a
30
+ SubagentStop hook can itself keep the subagent running. See [subagent capabilities](subagents.md).
31
+
24
32
  ### Example: Blocking Dangerous Commands
25
33
 
26
34
  ```ruby
@@ -69,6 +77,21 @@ end.wait
69
77
 
70
78
  See [examples/hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/hooks_example.rb), [examples/advanced_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/advanced_hooks_example.rb), and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb).
71
79
 
80
+ ### Hook cancellation
81
+
82
+ Dispatched hooks receive `HookContext#request_id` and `HookContext#signal`, using
83
+ the same [cooperative cancellation API](#permission-request-cancellation) as
84
+ permission callbacks. The request ID identifies this invocation, not the hook's
85
+ registered callback ID or the tool invocation.
86
+
87
+ CLI cancellation, EOF, disconnect, callback failure, and `HookMatcher#timeout`
88
+ invalidate the signal. Successful hook completion does not. A default `:thread`
89
+ hook waiting on external work must poll `signal.cancelled?` or use a bounded
90
+ wait; timeout does not forcibly stop its thread. Late hook output is discarded.
91
+ Inline hooks may unwind before the signal is marked cancelled on timeout, so
92
+ always clean up in `ensure`, regardless of the signal's value there. Do not
93
+ swallow cancellation exceptions. Observe the signal; `cancel` is SDK-internal.
94
+
72
95
  ## Permission Callbacks
73
96
 
74
97
  A **permission callback** is a Ruby proc/lambda that allows you to programmatically control tool execution. This gives you fine-grained control over what tools Claude can use and with what inputs.
@@ -108,6 +131,47 @@ end.wait
108
131
 
109
132
  See [examples/permission_callback_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/permission_callback_example.rb).
110
133
 
134
+ ### Permission request cancellation
135
+
136
+ Dispatched `can_use_tool` callbacks receive `context.request_id` (the control
137
+ request ID, distinct from `tool_use_id`) and `context.signal`, a
138
+ `ClaudeAgentSDK::CancellationSignal`:
139
+
140
+ - `signal.cancelled?` checks whether the request is no longer actionable.
141
+ - `signal.wait(timeout: seconds)` waits for cancellation, returning `true` on
142
+ cancellation or `false` on timeout. Omit the timeout to wait indefinitely.
143
+ Multiple waiters and callers arriving after cancellation all observe it.
144
+ - The SDK signals cancellation on a CLI `control_cancel_request`, disconnect,
145
+ EOF/transport failure, or an unsuccessful callback dispatch. Normal allow/deny
146
+ completion does not cancel the signal. Stop waiting once the callback returns.
147
+
148
+ The signal is safe on worker threads and Async fibers. Cancellation is
149
+ cooperative: default `:thread` callbacks are **not** forcibly terminated. Poll
150
+ the signal while waiting for an application's decision and remove the pending
151
+ request in `ensure`. Inline callbacks may instead unwind via `Async::Stop`;
152
+ let it propagate. The SDK never sends a late callback decision as an allow
153
+ response after it has observed cancellation.
154
+
155
+ For example, if the host has registered a separate `Thread::Queue` for this
156
+ request and its decision handler pushes a `PermissionResultAllow` or
157
+ `PermissionResultDeny` into it:
158
+
159
+ ```ruby
160
+ begin
161
+ loop do
162
+ break ClaudeAgentSDK::PermissionResultDeny.new(message: 'Request cancelled') if context.signal.cancelled?
163
+ decision = decision_queue.pop(timeout: 0.1)
164
+ break decision if decision
165
+ end
166
+ ensure
167
+ # Unregister context.request_id from the host's pending decisions here.
168
+ end
169
+ ```
170
+
171
+ Do not use an unbounded blocking `gets`/queue wait for a human decision without
172
+ observing cancellation, or auto-approve when the decision service disconnects.
173
+ Hooks have the same signal API; see [hook cancellation](#hook-cancellation).
174
+
111
175
  ### Shadowing: when `can_use_tool` never runs
112
176
 
113
177
  `can_use_tool` is only consulted when the CLI's permission ladder lands on
data/docs/sessions.md CHANGED
@@ -51,6 +51,34 @@ With `directory:` given, only that project and its git worktrees are searched (n
51
51
 
52
52
  > Each returned `SessionMessage` carries `parent_tool_use_id` — the id of the Agent `tool_use` block in the parent session that spawned this subagent — and `parent_agent_id`, the spawning subagent's id for nested subagents. Both are read from the `agent-<id>.meta.json` sidecar beside the transcript (or the `agent_metadata` entry in a `SessionStore`), and are `nil` when it is missing or unusable.
53
53
 
54
+ ### Reading Subagent Metadata
55
+
56
+ ```ruby
57
+ meta = ClaudeAgentSDK.get_subagent_metadata(session_id: session_id, agent_id: agent_id, directory: project)
58
+ meta = ClaudeAgentSDK.get_subagent_metadata_from_store(
59
+ session_store: store, session_id: session_id, agent_id: agent_id, directory: project
60
+ )
61
+ meta&.dig('toolUseId') # spawning Agent tool call; not task_id
62
+ meta&.dig('parentAgentId')
63
+ meta&.dig('agentType')
64
+ meta&.dig('spawnDepth')
65
+ ```
66
+
67
+ Returns a **string-keyed Hash with the original CLI field spelling**, preserving
68
+ unknown fields. All fields are optional. `nil` means unavailable; `{}` is a valid
69
+ empty sidecar. The disk reader uses the same project/worktree scope and sorted
70
+ first-match rule as `get_subagent_messages`, but does not parse the transcript.
71
+ It locates the sidecar beside `agent-<id>.jsonl`, so it returns `nil` until that
72
+ transcript file exists, even if the sidecar has already been written.
73
+ Missing, unreadable, non-regular, corrupt, or invalid-UTF-8 sidecars return `nil`.
74
+
75
+ The store reader resolves nested subpaths with `list_subkeys` when available,
76
+ otherwise tries the direct path. It returns the **last** `agent_metadata` entry
77
+ without the synthetic `type` marker, even before any conversation messages have
78
+ arrived. Adapter errors propagate, like other store reads. These APIs do not
79
+ return live status, and reading metadata does not resume an agent. See
80
+ [subagent capabilities](subagents.md) for correlating metadata with events.
81
+
54
82
  ## Renaming a Session
55
83
 
56
84
  ```ruby
data/docs/subagents.md ADDED
@@ -0,0 +1,198 @@
1
+ # Subagent capabilities
2
+
3
+ This repository provides SDK capabilities and minimal usage examples. UI,
4
+ application status aggregation, event persistence, and recovery policy belong
5
+ to the consuming application, not this SDK.
6
+
7
+ ## Available data and controls
8
+
9
+ | Capability | SDK surface | Contract |
10
+ |---|---|---|
11
+ | Define subagents | `ClaudeAgentOptions#agents`, `AgentDefinition` | Configure description, prompt, tools, model, and other agent options |
12
+ | Task lifecycle | `TaskStartedMessage`, `TaskProgressMessage`, `TaskUpdatedMessage`, `TaskNotificationMessage` | Task IDs identify tasks, including non-agent tasks; `task_type: 'local_agent'` identifies subagents |
13
+ | Activity and usage | Task progress / notification fields | Usage is cumulative, not a delta; optional fields depend on the CLI |
14
+ | Card fields | `TaskStartedMessage#subagent_type` / `#is_backgrounded` / `#spawn_depth`, `TaskProgressMessage#subagent_type` | All optional. `is_backgrounded` is tri-state: `true` background, `false` foreground (spawning tool call blocking), `nil` not reported |
15
+ | Display flags | `#skip_transcript` / `#ambient` on `TaskStartedMessage` and `TaskNotificationMessage` | Optional booleans (`nil` absent, `false` preserved). Hints for the host; the SDK never filters frames or computes activity |
16
+ | Progress summaries | `agent_progress_summaries: true`, `TaskProgressMessage#summary` | `true` requests model-generated one-line statuses for `local_agent` tasks; `summary` may then be present but stays optional. An enable switch, not a live toggle. Unset omits the key from `initialize` |
17
+ | Patch fields | `TaskUpdatedMessage#is_backgrounded` / `#error` / `#end_time` / `#total_paused_ms` / `#description` | Derived from `patch`; `nil` means "not in this patch", not a value. `is_backgrounded == true` is a move to the background |
18
+ | Settle details | `TaskNotificationMessage#reason` / `#resource_links` | `reason` is `'worker_restart'` or `nil`; `resource_links` is a raw Array for completed `mcp_task` tasks |
19
+ | Live background set | `BackgroundTasksChangedMessage#tasks` | Level signal with REPLACE semantics: swap your set for each payload. Background tasks only |
20
+ | Auto-denied tool calls | `PermissionDeniedMessage` | Advisory; `agent_id` routes a denial to a subagent. `ResultMessage#permission_denials` stays authoritative |
21
+ | Child output | `forward_subagent_text: true`, `AssistantMessage#parent_tool_use_id` | Forward child text/thinking; the parent tool ID identifies the spawning tool call |
22
+ | Agent identity | `SubagentStartHookInput`, `SubagentStopHookInput`, `get_subagent_metadata[_from_store]` | Metadata can link `agent_id` to `toolUseId`, `agentType`, `parentAgentId`, and `spawnDepth` |
23
+ | Historical transcripts | `list_subagents[_from_store]`, `get_subagent_messages[_from_store]` | Historical reads, not proof that an agent is currently alive |
24
+ | Background snapshots | `StopHookInput` / `SubagentStopHookInput` | Optional `background_tasks` and `session_crons`; `nil` means absent, `[]` means explicitly empty |
25
+ | Permissions | `can_use_tool`, `ToolPermissionContext` | `request_id`, `agent_id`, `tool_use_id`, and cooperative cancellation via `signal.cancelled?` / `signal.wait` |
26
+ | Hook cancellation | `HookContext#signal`, `HookContext#request_id` | Per-invocation cancellation on CLI cancel, disconnect, failure, or hook timeout; successful completion does not cancel |
27
+ | Stop a task | `Client#stop_task(task_id)` | Acknowledgement is not completion; observe a terminal event |
28
+ | Send to background | `Client#background_tasks(tool_use_id: nil)` | Keyed by the spawning `tool_use_id`, not `task_id` / `agent_id`. Targeted: returns `{ backgrounded: true }` or `{ backgrounded: false }` (a definitive miss). `nil`: every foreground task, returns `{}`. `''` / non-String raises `ArgumentError` |
29
+
30
+ Task updates expose raw statuses such as `pending`, `running`, `paused`,
31
+ `completed`, `failed`, and `killed`. Notifications use `completed`, `failed`,
32
+ and `stopped`. A terminal state can arrive through **either** event type;
33
+ `TERMINAL_TASK_STATUSES` covers both vocabularies. Receiving `paused` does not
34
+ imply a public per-agent pause/resume control exists.
35
+
36
+ ### Foreground, background, and the live set
37
+
38
+ A subagent registered in the **foreground** reports `TaskStartedMessage#is_backgrounded`
39
+ as `false`: the spawning Agent tool call blocks the turn. Test `== false`, not
40
+ falsiness — `nil` means the CLI did not report the field (it is set only for
41
+ `local_agent` and `local_bash` tasks). A later move to the background does not
42
+ re-emit `task_started`; it arrives as a `TaskUpdatedMessage` whose
43
+ `is_backgrounded` is `true`. The patch readers (`is_backgrounded`, `error`,
44
+ `end_time`, `total_paused_ms`, `description`) mirror how `status` is derived: a
45
+ patch carries only what changed, so merge patches into your own task map instead
46
+ of reading one as the task's full state.
47
+
48
+ `Client#background_tasks` is the control-request equivalent of pressing Ctrl+B:
49
+ each targeted blocking tool call returns a "running in the background"
50
+ tool_result, the turn continues, and the task still emits a
51
+ `TaskNotificationMessage` when it settles.
52
+
53
+ ```ruby
54
+ client.background_tasks # every foreground task => {}
55
+ client.background_tasks(tool_use_id: 'toolu_01') # one task => { backgrounded: true } or { backgrounded: false }
56
+ ```
57
+
58
+ `tool_use_id` is the id of the `tool_use` block that **spawned** the task
59
+ (`TaskStartedMessage#tool_use_id`, or `AssistantMessage#parent_tool_use_id` on
60
+ forwarded child output) — not a `task_id` or `agent_id`.
61
+
62
+ - **Targeted** (`tool_use_id:` given): the reply is the outcome.
63
+ `{ backgrounded: true }` means the matching foreground task was backgrounded.
64
+ `{ backgrounded: false }` is a **definitive miss** — the CLI found no
65
+ matching foreground task. Do not wait for an event after a miss.
66
+ - **All tasks** (`nil`, the explicit all-tasks form): the reply is `{}`, which
67
+ says nothing about whether any foreground task existed.
68
+
69
+ Event observation — `TaskUpdatedMessage#is_backgrounded` and
70
+ `BackgroundTasksChangedMessage` — is for the lifecycle state that follows, not a
71
+ substitute for reading the targeted reply.
72
+
73
+ `tool_use_id` must be `nil` or a non-empty String; anything else raises
74
+ `ArgumentError` before a request is written. The CLI itself treats `''` as "all
75
+ tasks", so a selector built from a missing id (`started.tool_use_id.to_s`) would
76
+ release every blocking tool call — and `TaskStartedMessage#tool_use_id` is
77
+ optional on the wire. Never substitute `nil` or `''` for a per-card id you do
78
+ not have yet: disable the per-task control until a real id arrives.
79
+
80
+ `skip_transcript` and `ambient` (on `TaskStartedMessage` and
81
+ `TaskNotificationMessage`) are the CLI's display hints. `skip_transcript` marks
82
+ an ambient/housekeeping task: hide it from the inline transcript, though it may
83
+ still appear in a tasks panel. `ambient` marks tasks that are not activity —
84
+ every `skip_transcript` task plus every live-update watcher, requested or
85
+ auto-started — which hosts should exclude from activity indicators. Both are
86
+ `nil` when absent and keep an explicit `false`. The SDK surfaces every frame
87
+ regardless and computes no activity state from them.
88
+
89
+ `BackgroundTasksChangedMessage#tasks` is the full set of live **background**
90
+ tasks after a membership change, as raw symbol-keyed Hashes
91
+ (`{ task_id:, task_type:, description:, ambient: }`, `ambient` optional — `true`
92
+ marks housekeeping tasks to exclude from activity indicators). It is a level
93
+ signal with **REPLACE semantics**: replace your set with each payload rather
94
+ than pairing start/notification edges, so a missed edge cannot leave a stale
95
+ "running" badge. A frame is emitted on membership changes and also when an
96
+ entry's `ambient` flag flips. REPLACE alone does not prevent a stale badge; the
97
+ CLI's contract also says:
98
+
99
+ - Ordering relative to the edge frames is unspecified, so do not correlate the
100
+ two streams.
101
+ - The level is per CLI process. Nothing is emitted at startup, so reset to the
102
+ empty set whenever the process (re)starts — otherwise the previous process's
103
+ last payload shows running work forever while the new one sits idle.
104
+ - `tasks: []` is an authoritative empty snapshot for that process.
105
+ - A repeated `initialize` on an already-running process is answered with a
106
+ snapshot (even an empty one) right behind its success response; older CLIs
107
+ may send nothing there. This SDK initializes once per connection.
108
+ - A foreground subagent is absent from the set until it is backgrounded.
109
+
110
+ The SDK does not consume this frame for its own bookkeeping, and does not
111
+ aggregate it into a status model.
112
+
113
+ `PermissionDeniedMessage` reports a tool call that was auto-denied without an
114
+ interactive prompt (`tool_name`, `tool_use_id`, `message`, and optionally
115
+ `agent_id`, `decision_reason_type`, `decision_reason`). It is a **best-effort
116
+ advisory, not a complete denial feed**: `ResultMessage#permission_denials` is
117
+ the authoritative record, and in rare races a booked denial has no frame or a
118
+ frame has no booked denial — do not derive badge counts or permission state
119
+ from it. Not covered at all: PreToolUse hook denies, deny-rule overrides of a
120
+ hook's allow/ask decision, Read/Edit/Write calls refused by a path-scoped deny
121
+ rule, and the MCP `--permission-prompt-tool` surface. `decision_reason_type` is
122
+ an open string (`'classifier'`, `'asyncAgent'`, `'mode'`, `'rule'` are
123
+ examples, not an enum), and `agent_id` is a subagent id for routing — not a
124
+ permission `request_id`. With a `can_use_tool` callback an "ask"
125
+ decision goes to the callback instead; without one nobody can answer it, so that
126
+ implicit denial is reported here too.
127
+
128
+ `TaskNotificationMessage#resource_links` passes the CLI's array through
129
+ untouched: symbol-keyed Hashes with the wire spelling preserved — `:uri` and
130
+ `:name` always, plus optional `:title`, `:description`, `:mimeType`, `:size` (a
131
+ number, not necessarily an integer) and `:annotations`. Elements carry no
132
+ `type: 'resource_link'` discriminator. The CLI describes its output as at most
133
+ 50 links / 64 KiB; that is a producer-side note the SDK does not enforce.
134
+
135
+ Other frames the CLI marks internal (`agents_killed`, `task_summary`, and
136
+ `permission_denied`'s `decision_reason_code`) arrive as a generic
137
+ `SystemMessage` / through `#data` with no stability promise.
138
+
139
+ **Provenance.** The fields, messages, control request, and option in this
140
+ section were read from the schema embedded in Claude Code CLI 2.1.278. They are
141
+ covered by protocol unit tests, but only partly verified against a live CLI: a
142
+ smoke run against 2.1.278 confirmed `task_started`'s `subagent_type` /
143
+ `is_backgrounded` / `spawn_depth` and the `{ backgrounded: false }` answer to a
144
+ targeted miss. Actually backgrounding a task, `background_tasks_changed` and
145
+ `permission_denied` frames, and progress summaries are schema-derived only.
146
+ Every new reader is `nil` whenever the CLI does not send its field. The
147
+ Python SDK exposes none of them as of Python SDK 0.2.153.
148
+
149
+ `task_id`, `agent_id`, and `tool_use_id` are different identifiers. The metadata
150
+ field `toolUseId` links an agent to its spawning tool call, not to an inner tool
151
+ call awaiting permission. Metadata may be unavailable at the start hook; retry
152
+ on later events or history reads. See [metadata APIs](sessions.md#reading-subagent-metadata).
153
+
154
+ Both Stop snapshots describe the **parent session's background work**, even on
155
+ SubagentStop. They are not a complete agent registry, and absence from a snapshot
156
+ does not establish completion. A SubagentStop hook can itself prevent stopping.
157
+ See [hook fields and permission cancellation](hooks-and-permissions.md).
158
+
159
+ ## Minimal example
160
+
161
+ [`examples/subagent_status_example.rb`](../examples/subagent_status_example.rb)
162
+ defines a tool-free reviewer, registers lifecycle hooks, reads metadata, and
163
+ prints task events and forwarded child text without building a status model:
164
+
165
+ ```sh
166
+ bundle exec ruby examples/subagent_status_example.rb
167
+ ```
168
+
169
+ It requires an authenticated Claude Code CLI and makes a real model request.
170
+ The example limits the parent to the Agent tool and gives the reviewer no tools.
171
+ Events and optional metadata depend on the CLI version and actual execution;
172
+ not every run emits every event type. Output can contain prompts and transcripts.
173
+
174
+ The example reads with `Client#receive_messages` for a bounded 60-second window,
175
+ then disconnects. `receive_response` stops at the next parent result, which is
176
+ not the end of background work. The demo deadline is not a task status. Use the
177
+ store-backed metadata API when the CLI filesystem is remote; see
178
+ [permission cancellation](hooks-and-permissions.md#permission-request-cancellation)
179
+ for a minimal cancellation-aware callback wait.
180
+
181
+ The SDK does not provide an authoritative live-agent registry, a UI status
182
+ machine, or direct per-agent pause/resume controls. Reading metadata or restoring
183
+ transcripts does not reconnect to a running subagent.
184
+
185
+ ## CLI contract checks
186
+
187
+ The real-CLI suite includes subagent ID/metadata/text correlation, permission
188
+ cancellation on interrupt, and background completion/stop after a parent result:
189
+
190
+ ```sh
191
+ RUN_INTEGRATION=1 bundle exec rspec spec/integration/real_cli_integration_spec.rb --example 'subagent contracts'
192
+ ```
193
+
194
+ These checks use disposable settings/transcripts and cap each run at $0.50 and
195
+ 120 seconds. They require `claude` on PATH and `ANTHROPIC_API_KEY`; without either
196
+ they are **skipped**, not verified. They exercise the installed CLI, not every
197
+ supported CLI version. Protocol unit tests cover both callback scheduling modes
198
+ and terminal event variants deterministically.
data/docs/types.md CHANGED
@@ -50,26 +50,56 @@ end
50
50
 
51
51
  # Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works)
52
52
  class TaskStartedMessage < SystemMessage
53
- attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type, :workflow_name, :prompt
53
+ attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type, :workflow_name, :prompt,
54
+ :subagent_type, # String | nil
55
+ :is_backgrounded, # true (background) | false (foreground, tool call blocking) | nil (not reported)
56
+ :spawn_depth, # Integer | nil (1 = top-level subagent)
57
+ :skip_transcript, # true | false | nil — hide from the inline transcript (a tasks panel may still show it)
58
+ :ambient # true | false | nil — not activity; exclude from activity indicators
54
59
  end
55
60
 
56
61
  class TaskProgressMessage < SystemMessage
57
- attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name, :summary
62
+ attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name, :summary,
63
+ :subagent_type # String | nil
58
64
  end
59
65
 
60
66
  class TaskNotificationMessage < SystemMessage
61
- attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage
67
+ attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage,
68
+ :reason, # 'worker_restart' | nil
69
+ :resource_links, # Array<Hash> | nil — raw, symbol keys with wire spelling (:uri, :name, :mimeType, ...)
70
+ :skip_transcript, # true | false | nil — same meaning as on TaskStartedMessage
71
+ :ambient # true | false | nil — the SDK never filters on either flag
62
72
  end
63
73
 
64
74
  # Background task lifecycle state change. `status` is derived from patch["status"].
65
75
  # A terminal task can arrive *only* as a TaskUpdatedMessage (no TaskNotificationMessage) —
66
76
  # e.g. a TaskStop-killed task reports status "killed" here. Clear tracked task IDs on a
67
77
  # terminal status (see TERMINAL_TASK_STATUSES) from *either* message.
78
+ # The other patch readers are derived the same way; nil means "not in this patch".
68
79
  class TaskUpdatedMessage < SystemMessage
69
- attr_accessor :task_id, :patch, :status, :uuid, :session_id
80
+ attr_accessor :task_id, :patch, :status, :uuid, :session_id,
81
+ :description, :error, :end_time, :total_paused_ms,
82
+ :is_backgrounded # true = moved to the background | false | nil (patch does not mention it)
83
+ end
84
+
85
+ # Full set of live background tasks; REPLACE semantics (swap your set for each payload).
86
+ class BackgroundTasksChangedMessage < SystemMessage
87
+ attr_accessor :tasks, # Array<Hash> — raw { task_id:, task_type:, description:, ambient: }
88
+ :uuid, :session_id
89
+ end
90
+
91
+ # A tool call auto-denied without an interactive prompt. Best-effort advisory, not a
92
+ # complete denial feed; ResultMessage#permission_denials is the authoritative record.
93
+ class PermissionDeniedMessage < SystemMessage
94
+ attr_accessor :tool_name, :tool_use_id, :message, :uuid, :session_id,
95
+ :agent_id, # String | nil — subagent id for routing (NOT a permission request_id)
96
+ :decision_reason_type, # String | nil — open string ('classifier', 'asyncAgent', 'mode', 'rule', ...)
97
+ :decision_reason # String | nil
70
98
  end
71
99
  ```
72
100
 
101
+ See [subagent capabilities](subagents.md) for the contracts behind these fields.
102
+
73
103
  ### ResultMessage
74
104
 
75
105
  Final result message with cost and usage information.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # Cooperative cancellation of a permission or hook request. Safe to observe from
5
+ # worker threads and Async fibers. Cancellation does not kill user threads.
6
+ class CancellationSignal
7
+ def initialize
8
+ @queue = Thread::Queue.new
9
+ end
10
+
11
+ def cancelled?
12
+ @queue.closed?
13
+ end
14
+
15
+ # Wait for cancellation, returning true when cancelled, false on timeout.
16
+ # Level-triggered: late callers and multiple waiters all observe cancellation.
17
+ # @param timeout [Numeric, nil] Seconds; nil waits without a deadline
18
+ def wait(timeout: nil) # rubocop:disable Naming/PredicateMethod -- blocking wait, not a state predicate
19
+ @queue.pop(timeout: timeout)
20
+ cancelled?
21
+ end
22
+
23
+ # @api private Called by the SDK when the request is no longer actionable.
24
+ def cancel
25
+ @queue.close
26
+ end
27
+ end
28
+ end
@@ -133,7 +133,9 @@ module ClaudeAgentSDK
133
133
  # like every other system subtype. `data` is always symbol-keyed here:
134
134
  # `parse` rejects any message lacking a `:type` symbol key, so a
135
135
  # string-keyed hash never reaches these classes.
136
- 'task_updated' => TaskUpdatedMessage
136
+ 'task_updated' => TaskUpdatedMessage,
137
+ 'background_tasks_changed' => BackgroundTasksChangedMessage,
138
+ 'permission_denied' => PermissionDeniedMessage
137
139
  }.freeze
138
140
 
139
141
  def self.parse_system_message(data)