claude-agent-sdk 0.16.7 → 0.16.9

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.
data/docs/types.md ADDED
@@ -0,0 +1,187 @@
1
+ # Types Reference
2
+
3
+ See [lib/claude_agent_sdk/types.rb](../lib/claude_agent_sdk/types.rb) for complete type definitions.
4
+
5
+ ## Message Types
6
+
7
+ ```ruby
8
+ # Union type of all possible messages
9
+ Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage
10
+ ```
11
+
12
+ ### UserMessage
13
+
14
+ User input message.
15
+
16
+ ```ruby
17
+ class UserMessage
18
+ attr_accessor :content, # String | Array<ContentBlock>
19
+ :uuid, # String | nil - Unique ID for rewind support
20
+ :parent_tool_use_id, # String | nil
21
+ :tool_use_result # Hash | nil - Tool result data when message is a tool response
22
+ end
23
+ ```
24
+
25
+ ### AssistantMessage
26
+
27
+ Assistant response message with content blocks.
28
+
29
+ ```ruby
30
+ class AssistantMessage
31
+ attr_accessor :content, # Array<ContentBlock>
32
+ :model, # String
33
+ :parent_tool_use_id, # String | nil
34
+ :error, # String | nil ('authentication_failed', 'billing_error', 'rate_limit', 'invalid_request', 'server_error', 'unknown')
35
+ :usage # Hash | nil - Token usage info from the API response
36
+ end
37
+ ```
38
+
39
+ ### SystemMessage
40
+
41
+ System message with metadata. Task lifecycle events are typed subclasses.
42
+
43
+ ```ruby
44
+ class SystemMessage
45
+ attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', etc.)
46
+ :data # Hash
47
+ end
48
+
49
+ # Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works)
50
+ class TaskStartedMessage < SystemMessage
51
+ attr_accessor :task_id, :description, :uuid, :session_id, :tool_use_id, :task_type
52
+ end
53
+
54
+ class TaskProgressMessage < SystemMessage
55
+ attr_accessor :task_id, :description, :usage, :uuid, :session_id, :tool_use_id, :last_tool_name
56
+ end
57
+
58
+ class TaskNotificationMessage < SystemMessage
59
+ attr_accessor :task_id, :status, :output_file, :summary, :uuid, :session_id, :tool_use_id, :usage
60
+ end
61
+ ```
62
+
63
+ ### ResultMessage
64
+
65
+ Final result message with cost and usage information.
66
+
67
+ ```ruby
68
+ class ResultMessage
69
+ attr_accessor :subtype, # String
70
+ :duration_ms, # Integer
71
+ :duration_api_ms, # Integer
72
+ :is_error, # Boolean
73
+ :num_turns, # Integer
74
+ :session_id, # String
75
+ :stop_reason, # String | nil ('end_turn', 'max_tokens', 'stop_sequence')
76
+ :total_cost_usd, # Float | nil
77
+ :usage, # Hash | nil
78
+ :result, # String | nil (final text result)
79
+ :structured_output # Hash | nil (when using output_format)
80
+ end
81
+ ```
82
+
83
+ ## Content Block Types
84
+
85
+ ```ruby
86
+ # Union type of all content blocks
87
+ ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | UnknownBlock
88
+ ```
89
+
90
+ ### TextBlock
91
+
92
+ ```ruby
93
+ class TextBlock
94
+ attr_accessor :text # String
95
+ end
96
+ ```
97
+
98
+ ### ThinkingBlock
99
+
100
+ For models with extended thinking capability.
101
+
102
+ ```ruby
103
+ class ThinkingBlock
104
+ attr_accessor :thinking, # String
105
+ :signature # String
106
+ end
107
+ ```
108
+
109
+ ### ToolUseBlock
110
+
111
+ Tool use request block.
112
+
113
+ ```ruby
114
+ class ToolUseBlock
115
+ attr_accessor :id, # String
116
+ :name, # String
117
+ :input # Hash
118
+ end
119
+ ```
120
+
121
+ ### ToolResultBlock
122
+
123
+ Tool execution result block.
124
+
125
+ ```ruby
126
+ class ToolResultBlock
127
+ attr_accessor :tool_use_id, # String
128
+ :content, # String | Array<Hash> | nil
129
+ :is_error # Boolean | nil
130
+ end
131
+ ```
132
+
133
+ ### UnknownBlock
134
+
135
+ Generic content block for types the SDK doesn't explicitly handle (e.g., `document` for PDFs, `image` for inline images). Preserves the raw data for forward compatibility with newer CLI versions.
136
+
137
+ ```ruby
138
+ class UnknownBlock
139
+ attr_accessor :type, # String — the original block type (e.g., "document")
140
+ :data # Hash — the full raw block hash
141
+ end
142
+ ```
143
+
144
+ ## Configuration Types
145
+
146
+ | Type | Description |
147
+ |------|-------------|
148
+ | `Configuration` | Global defaults via `ClaudeAgentSDK.configure` block |
149
+ | `ClaudeAgentOptions` | Main configuration for queries and clients |
150
+ | `HookMatcher` | Hook configuration with matcher pattern and timeout |
151
+ | `PermissionResultAllow` | Permission callback result to allow tool use |
152
+ | `PermissionResultDeny` | Permission callback result to deny tool use |
153
+ | `AgentDefinition` | Agent definition with description, prompt, tools, model, skills, memory, mcp_servers |
154
+ | `ThinkingConfigAdaptive` | Adaptive thinking mode (CLI dynamically adjusts budget) |
155
+ | `ThinkingConfigEnabled` | Enabled thinking with explicit `budget_tokens` |
156
+ | `ThinkingConfigDisabled` | Disabled thinking |
157
+ | `SdkMcpTool` | SDK MCP tool definition with name, description, input_schema, handler, annotations |
158
+ | `McpStdioServerConfig` | MCP server config for stdio transport |
159
+ | `McpSSEServerConfig` | MCP server config for SSE transport |
160
+ | `McpHttpServerConfig` | MCP server config for HTTP transport |
161
+ | `SdkPluginConfig` | SDK plugin configuration |
162
+ | `McpServerStatus` | Status of a single MCP server connection (with `.parse`) |
163
+ | `McpStatusResponse` | Response from `get_mcp_status` containing all server statuses (with `.parse`) |
164
+ | `McpServerInfo` | MCP server name and version |
165
+ | `McpToolInfo` | MCP tool name, description, and annotations |
166
+ | `McpToolAnnotations` | MCP tool annotation hints (`read_only`, `destructive`, `open_world`) |
167
+ | `TaskUsage` | Typed usage data (`total_tokens`, `tool_uses`, `duration_ms`) with `from_hash` factory |
168
+ | `SDKSessionInfo` | Session metadata from `list_sessions` |
169
+ | `SessionMessage` | Single message from `get_session_messages` |
170
+ | `SandboxSettings` | Sandbox settings for isolated command execution |
171
+ | `SandboxNetworkConfig` | Network configuration for sandbox |
172
+ | `SandboxIgnoreViolations` | Configure which sandbox violations to ignore |
173
+ | `SystemPromptPreset` | System prompt preset configuration |
174
+ | `ToolsPreset` | Tools preset configuration for base tools selection |
175
+
176
+ ## Constants
177
+
178
+ | Constant | Description |
179
+ |----------|-------------|
180
+ | `SDK_BETAS` | Available beta features (e.g., `"context-1m-2025-08-07"`) |
181
+ | `PERMISSION_MODES` | Available permission modes |
182
+ | `SETTING_SOURCES` | Available setting sources |
183
+ | `HOOK_EVENTS` | Available hook events |
184
+ | `ASSISTANT_MESSAGE_ERRORS` | Possible error types in AssistantMessage |
185
+ | `TASK_NOTIFICATION_STATUSES` | Task lifecycle notification statuses (`completed`, `failed`, `stopped`) |
186
+ | `MCP_SERVER_CONNECTION_STATUSES` | MCP server connection states (`connected`, `failed`, `needs-auth`, `pending`, `disabled`) |
187
+ | `EFFORT_LEVELS` | Effort levels (`low`, `medium`, `high`, `xhigh`, `max`) |
@@ -103,6 +103,11 @@ module ClaudeAgentSDK
103
103
  end
104
104
 
105
105
  def append_session(cmd)
106
+ # `--continue` and `--resume <id>` are mutually exclusive session-restore
107
+ # modes. Passing both surfaces as a generic non-zero CLI exit, which is
108
+ # painful to debug at the caller; raise early in the SDK stack instead.
109
+ raise ArgumentError, "continue_conversation and resume are mutually exclusive" if @options.continue_conversation && @options.resume
110
+
106
111
  cmd.push("--continue") if @options.continue_conversation
107
112
  cmd.push("--resume", @options.resume) if @options.resume
108
113
  append_resume_session_at(cmd)
@@ -153,6 +153,14 @@ module ClaudeAgentSDK
153
153
  content: get.call(:content),
154
154
  is_error: get.call(:is_error)
155
155
  )
156
+ when 'server_tool_use'
157
+ ServerToolUseBlock.new(id: get.call(:id), name: get.call(:name), input: get.call(:input))
158
+ when 'server_tool_result'
159
+ ServerToolResultBlock.new(
160
+ tool_use_id: get.call(:tool_use_id),
161
+ content: get.call(:content),
162
+ is_error: get.call(:is_error)
163
+ )
156
164
  else
157
165
  # Forward-compatible: preserve unrecognized content block types (e.g., "document", "image")
158
166
  # so newer CLI versions don't crash older SDK versions.
@@ -22,6 +22,10 @@ module ClaudeAgentSDK
22
22
 
23
23
  CONTROL_REQUEST_TIMEOUT_ENV_VAR = 'CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS'
24
24
  DEFAULT_CONTROL_REQUEST_TIMEOUT_SECONDS = 1200.0
25
+ # NOTE: CLAUDE_CODE_STREAM_CLOSE_TIMEOUT is defined by the CLI in
26
+ # MILLISECONDS (Python SDK uses `int(os.environ[...])/1000`); the SDK
27
+ # divides by 1000 to obtain seconds. The default below is *seconds*
28
+ # for direct use without env conversion (60 s = the CLI's 60000 ms).
25
29
  STREAM_CLOSE_TIMEOUT_ENV_VAR = 'CLAUDE_CODE_STREAM_CLOSE_TIMEOUT'
26
30
  DEFAULT_STREAM_CLOSE_TIMEOUT_SECONDS = 60.0
27
31
 
@@ -119,13 +123,31 @@ module ClaudeAgentSDK
119
123
  response
120
124
  end
121
125
 
122
- # Start reading messages from transport
126
+ # Start reading messages from transport.
127
+ #
128
+ # Spawns `read_messages` as a direct child task of the current Async
129
+ # task and stores that child in `@task`. An earlier version wrapped
130
+ # `task.async { read_messages }` inside an outer `Async do ... end` and
131
+ # assigned the outer task to `@task`; the outer task completed almost
132
+ # immediately after spawning, so `close`'s `@task.stop` never reached
133
+ # the actual `read_messages` fiber and the read loop kept running
134
+ # until the transport raised. Now `@task.stop` stops the read loop.
135
+ #
136
+ # Must be called inside an Async{} block (matches `query()` which wraps
137
+ # its own internals in Async, and the documented `Client#connect`
138
+ # pattern). If invoked outside a reactor, raise a clear error rather
139
+ # than letting Async::Task.current raise an opaque "No async task
140
+ # available!" — earlier versions of this method *appeared* to work
141
+ # from synchronous callers but actually hung indefinitely because the
142
+ # outer Async{} root task waited for read_messages to finish, which
143
+ # never happens for a live Client.
123
144
  def start
124
145
  return if @task
125
146
 
126
- @task = Async do |task|
127
- task.async { read_messages }
128
- end
147
+ parent = Async::Task.current?
148
+ raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
149
+
150
+ @task = parent.async { read_messages }
129
151
  end
130
152
 
131
153
  private
@@ -644,23 +666,30 @@ module ClaudeAgentSDK
644
666
 
645
667
  writeln(JSON.generate(control_request))
646
668
 
647
- # Wait for response with timeout (default 1200s to handle slow CLI startup)
648
- Async do |task|
649
- task.with_timeout(timeout_seconds) do
669
+ # Wait for response with timeout. Use the current task's timeout so we
670
+ # stay in the caller's fiber (a nested `Async do ... end.wait` spawned a
671
+ # separate task and could leak the pending entries when an Async::Stop
672
+ # propagated through `.wait` before either the success-path or the
673
+ # timeout-path cleanup ran). Control requests must run inside an Async
674
+ # reactor — `Query#start` already enforces this precondition, so the
675
+ # cleanest place to surface the contract is the start hand-off; here we
676
+ # assume an active task is present.
677
+ begin
678
+ Async::Task.current.with_timeout(timeout_seconds) do
650
679
  condition.wait
651
680
  end
652
-
653
- result = @pending_control_results.delete(request_id)
654
- @pending_control_responses.delete(request_id)
655
-
681
+ result = @pending_control_results[request_id]
656
682
  raise result if result.is_a?(Exception)
657
683
 
658
- result[:response] || {}
659
- end.wait
660
- rescue Async::TimeoutError
661
- @pending_control_responses.delete(request_id)
662
- @pending_control_results.delete(request_id)
663
- raise ControlRequestTimeoutError, "Control request timeout: #{request[:subtype]}"
684
+ result&.[](:response) || {}
685
+ rescue Async::TimeoutError
686
+ raise ControlRequestTimeoutError, "Control request timeout: #{request[:subtype]}"
687
+ ensure
688
+ # Always evict the entries so a late control_response (after timeout)
689
+ # or an Async::Stop propagating through wait does not leak state.
690
+ @pending_control_responses.delete(request_id)
691
+ @pending_control_results.delete(request_id)
692
+ end
664
693
  end
665
694
 
666
695
  def handle_sdk_mcp_request(server_name, message)
@@ -111,8 +111,10 @@ module ClaudeAgentSDK
111
111
  resource = @resources.find { |r| r.uri == uri }
112
112
  raise "Resource '#{uri}' not found" unless resource
113
113
 
114
- # Call the resource's reader
115
- content = resource.reader.call
114
+ # Hop off the Fiber scheduler before invoking user code — same reason
115
+ # as `call_tool` above: reader blocks may touch Thread.current-keyed
116
+ # libraries (ActiveRecord, pg, ...) and must run on a plain thread.
117
+ content = FiberBoundary.invoke { resource.reader.call }
116
118
 
117
119
  # Ensure content has the expected format
118
120
  unless content.is_a?(Hash) && content[:contents]
@@ -142,8 +144,9 @@ module ClaudeAgentSDK
142
144
  prompt = @prompts.find { |p| p.name == name }
143
145
  raise "Prompt '#{name}' not found" unless prompt
144
146
 
145
- # Call the prompt's generator
146
- result = prompt.generator.call(arguments)
147
+ # Hop off the Fiber scheduler before invoking user code — same reason
148
+ # as `call_tool` above.
149
+ result = FiberBoundary.invoke { prompt.generator.call(arguments) }
147
150
 
148
151
  # Ensure result has the expected format
149
152
  unless result.is_a?(Hash) && result[:messages]
@@ -276,7 +279,9 @@ module ClaudeAgentSDK
276
279
  end
277
280
 
278
281
  def read
279
- result = @resource_def.reader.call
282
+ # Hop off the Fiber scheduler before invoking user code so the
283
+ # async gem's scheduler is not visible to ActiveRecord / pg.
284
+ result = ClaudeAgentSDK::FiberBoundary.invoke { @resource_def.reader.call }
280
285
 
281
286
  # Convert to MCP format
282
287
  result[:contents].map do |content|
@@ -315,7 +320,8 @@ module ClaudeAgentSDK
315
320
  end
316
321
 
317
322
  def get(**args)
318
- result = @prompt_def.generator.call(args)
323
+ # Hop off the Fiber scheduler before invoking user code (see read above).
324
+ result = ClaudeAgentSDK::FiberBoundary.invoke { @prompt_def.generator.call(args) }
319
325
 
320
326
  # Convert to MCP format
321
327
  {
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'securerandom'
5
+ require 'fileutils'
5
6
  require_relative 'sessions'
6
7
 
7
8
  module ClaudeAgentSDK
@@ -13,6 +14,13 @@ module ClaudeAgentSDK
13
14
  module SessionMutations # rubocop:disable Metrics/ModuleLength
14
15
  module_function
15
16
 
17
+ # Transcript entry types kept in fork output. Mirrors Python's
18
+ # `_TRANSCRIPT_TYPES`. Other types (custom-title, tag, aiTitle,
19
+ # permission-mode, etc.) carry session metadata and must not bleed
20
+ # into the fork's transcript body — they are reconstructed for the
21
+ # fork's own sessionId after the body is written.
22
+ TRANSCRIPT_TYPES = %w[user assistant attachment system progress].freeze
23
+
16
24
  # Rename a session by appending a custom-title entry.
17
25
  #
18
26
  # list_sessions reads the LAST custom-title from the file tail, so
@@ -83,6 +91,13 @@ module ClaudeAgentSDK
83
91
  rescue Errno::ENOENT
84
92
  raise Errno::ENOENT, "Session #{session_id} not found"
85
93
  end
94
+
95
+ # Subagent transcripts live in a sibling directory named after the
96
+ # session ID. Without removing it, the CLI would later pick up
97
+ # orphaned subagent state if the same session ID happened to be
98
+ # reused. Matches Python's `shutil.rmtree(path.parent / session_id)`.
99
+ subagent_dir = File.join(File.dirname(path), session_id)
100
+ FileUtils.rm_rf(subagent_dir) if File.directory?(subagent_dir)
86
101
  end
87
102
 
88
103
  # Fork a session into a new branch with fresh UUIDs.
@@ -111,7 +126,7 @@ module ClaudeAgentSDK
111
126
  file_size = File.size(file_path)
112
127
  raise ArgumentError, "Session #{session_id} has no messages to fork" if file_size.zero?
113
128
 
114
- transcript, content_replacements = parse_fork_transcript(file_path)
129
+ transcript, content_replacements = parse_fork_transcript(file_path, session_id)
115
130
  transcript.reject! { |e| e['isSidechain'] }
116
131
  raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
117
132
 
@@ -140,12 +155,18 @@ module ClaudeAgentSDK
140
155
  forked_session_id, session_id, now)
141
156
  end
142
157
 
143
- # Append content-replacement entry if any
158
+ # Append content-replacement entry if any. The entry needs `uuid` and
159
+ # `timestamp` so a *second* fork of this forked session can re-ingest
160
+ # it — `parse_fork_transcript` gates content-replacement on the entry
161
+ # being a valid hash with a matching `sessionId`, and the CLI's own
162
+ # tools index entries by uuid. Matches Python's `_emit_fork_to_disk`.
144
163
  if content_replacements && !content_replacements.empty?
145
164
  lines << JSON.generate({
146
165
  'type' => 'content-replacement',
147
166
  'sessionId' => forked_session_id,
148
- 'replacements' => content_replacements
167
+ 'replacements' => content_replacements,
168
+ 'uuid' => SecureRandom.uuid,
169
+ 'timestamp' => now
149
170
  })
150
171
  end
151
172
 
@@ -156,7 +177,9 @@ module ClaudeAgentSDK
156
177
  lines << JSON.generate({
157
178
  'type' => 'custom-title',
158
179
  'sessionId' => forked_session_id,
159
- 'customTitle' => fork_title
180
+ 'customTitle' => fork_title,
181
+ 'uuid' => SecureRandom.uuid,
182
+ 'timestamp' => now
160
183
  })
161
184
 
162
185
  fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
@@ -229,9 +252,17 @@ module ClaudeAgentSDK
229
252
  # Parse a fork transcript by streaming the JSONL file line-by-line.
230
253
  # Opens in binary mode and scrubs invalid UTF-8 so stray non-UTF-8
231
254
  # bytes in tool results do not raise Encoding::InvalidByteSequenceError.
232
- def parse_fork_transcript(file_path)
255
+ #
256
+ # Only `TRANSCRIPT_TYPES` entries with a string uuid are kept in the
257
+ # transcript body — `custom-title`, `tag`, `aiTitle`, `permission-mode`
258
+ # and other metadata entries are reconstructed for the new sessionId
259
+ # by the caller. `content-replacement` entries are collected across the
260
+ # entire file (one per compaction round) — concatenated rather than
261
+ # overwritten — and only kept if their `sessionId` matches the source.
262
+ # Matches Python's `_parse_fork_transcript` exactly.
263
+ def parse_fork_transcript(file_path, source_session_id = nil)
233
264
  transcript = []
234
- content_replacements = nil
265
+ content_replacements = []
235
266
 
236
267
  File.foreach(file_path, mode: 'rb') do |line|
237
268
  line = line.force_encoding('UTF-8').scrub
@@ -240,13 +271,16 @@ module ClaudeAgentSDK
240
271
  rescue JSON::ParserError
241
272
  next
242
273
  end
243
- next unless entry.is_a?(Hash) && entry['uuid']
244
-
245
- if entry['type'] == 'content-replacement'
246
- content_replacements = entry['replacements']
247
- next
274
+ next unless entry.is_a?(Hash)
275
+
276
+ entry_type = entry['type']
277
+ if TRANSCRIPT_TYPES.include?(entry_type) && entry['uuid'].is_a?(String)
278
+ transcript << entry
279
+ elsif entry_type == 'content-replacement' &&
280
+ (source_session_id.nil? || entry['sessionId'] == source_session_id) &&
281
+ entry['replacements'].is_a?(Array)
282
+ content_replacements.concat(entry['replacements'])
248
283
  end
249
- transcript << entry
250
284
  end
251
285
 
252
286
  [transcript, content_replacements]
@@ -472,16 +472,56 @@ module ClaudeAgentSDK
472
472
  by_id.values
473
473
  end
474
474
 
475
+ # Probe git for the worktree list with a hard 5-second cap. A stale
476
+ # git lock or hung network mount must not block the listing path
477
+ # forever. Stdlib `Timeout.timeout` raises across threads via
478
+ # `Thread#raise`, which corrupts the Async fiber-scheduler state when
479
+ # the caller is inside a reactor, so we drain stdout/stderr on side
480
+ # threads (so a full pipe buffer can't deadlock git) and SIGKILL the
481
+ # child if the deadline passes. Matches Python's
482
+ # `subprocess.run(..., timeout=5)`.
475
483
  def detect_worktrees(path)
476
- output, _err, status = Open3.capture3('git', '-C', path, 'worktree', 'list', '--porcelain')
477
- return [path] unless status.success?
484
+ stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain')
485
+ stdin.close
486
+
487
+ # Drain stdout/stderr concurrently — without this, a repo with enough
488
+ # worktrees to overrun the 64 KB pipe buffer causes git to block on
489
+ # write, wait_thr never finishes, and we hit the 5-second watchdog
490
+ # and silently lose every worktree path.
491
+ stdout_buf = +''
492
+ stdout_reader = Thread.new { stdout_buf << stdout.read.to_s }
493
+ stderr_reader = Thread.new { stderr.read }
494
+
495
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5.0
496
+ until wait_thr.join(0.1)
497
+ next if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
498
+
499
+ begin
500
+ Process.kill('KILL', wait_thr.pid)
501
+ rescue Errno::ESRCH
502
+ # Already exited between the join check and the kill.
503
+ end
504
+ wait_thr.join
505
+ stdout_reader.join(0.5)
506
+ stderr_reader.join(0.5)
507
+ return [path]
508
+ end
509
+
510
+ stdout_reader.join
511
+ stderr_reader.join
512
+
513
+ return [path] unless wait_thr.value.success?
478
514
 
479
- paths = output.lines.filter_map do |line|
515
+ paths = stdout_buf.lines.filter_map do |line|
480
516
  line.strip.delete_prefix('worktree ') if line.start_with?('worktree ')
481
517
  end
482
518
  paths.empty? ? [path] : paths
483
519
  rescue StandardError
484
520
  [path]
521
+ ensure
522
+ stdout_reader&.kill if stdout_reader&.alive?
523
+ stderr_reader&.kill if stderr_reader&.alive?
524
+ [stdout, stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier
485
525
  end
486
526
 
487
527
  def find_session_file(session_id, directory)