copilot-sdk-supercharged 1.0.3

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.
@@ -0,0 +1,825 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) Microsoft Corporation. All rights reserved.
4
+
5
+ require "open3"
6
+ require "socket"
7
+ require "thread"
8
+
9
+ module Copilot
10
+ # Main client for interacting with the Copilot CLI.
11
+ #
12
+ # The CopilotClient manages the connection to the Copilot CLI server and provides
13
+ # methods to create and manage conversation sessions. It can either spawn a CLI
14
+ # server process or connect to an existing server.
15
+ #
16
+ # @example
17
+ # client = Copilot::CopilotClient.new(cli_path: "/usr/local/bin/copilot")
18
+ # client.start
19
+ #
20
+ # session = client.create_session(model: "gpt-4")
21
+ # response = session.send_and_wait(prompt: "Hello!")
22
+ # puts response&.data&.dig("content")
23
+ #
24
+ # session.destroy
25
+ # client.stop
26
+ class CopilotClient
27
+ # @return [String] the current connection state
28
+ attr_reader :state
29
+
30
+ # Create a new CopilotClient.
31
+ #
32
+ # @param cli_path [String, nil] path to the Copilot CLI executable
33
+ # @param cli_args [Array<String>] extra arguments for the CLI
34
+ # @param cwd [String, nil] working directory for the CLI process
35
+ # @param port [Integer] TCP port (0 = random); ignored in stdio mode
36
+ # @param use_stdio [Boolean] use stdio transport (default: true)
37
+ # @param cli_url [String, nil] URL of an existing server ("host:port")
38
+ # @param log_level [String] log level for the CLI ("info", "debug", etc.)
39
+ # @param auto_start [Boolean] auto-start on first use (default: true)
40
+ # @param auto_restart [Boolean] auto-restart if the server crashes (default: true)
41
+ # @param env [Hash, nil] environment variables for the CLI process
42
+ # @param github_token [String, nil] GitHub token for authentication
43
+ # @param use_logged_in_user [Boolean, nil] use logged-in user auth (default: true unless github_token)
44
+ #
45
+ # @raise [ArgumentError] if mutually exclusive options are provided
46
+ def initialize(
47
+ cli_path: nil,
48
+ cli_args: [],
49
+ cwd: nil,
50
+ port: 0,
51
+ use_stdio: true,
52
+ cli_url: nil,
53
+ log_level: "info",
54
+ auto_start: true,
55
+ auto_restart: true,
56
+ env: nil,
57
+ github_token: nil,
58
+ use_logged_in_user: nil
59
+ )
60
+ # Validate mutually exclusive options
61
+ if cli_url && (use_stdio == false || cli_path)
62
+ # cli_url with explicit use_stdio=false is fine; but cli_url with cli_path is not
63
+ end
64
+ if cli_url && cli_path
65
+ raise ArgumentError, "cli_url is mutually exclusive with cli_path"
66
+ end
67
+ if cli_url && (github_token || !use_logged_in_user.nil?)
68
+ raise ArgumentError,
69
+ "github_token and use_logged_in_user cannot be used with cli_url " \
70
+ "(external server manages its own auth)"
71
+ end
72
+
73
+ @is_external_server = false
74
+ @actual_host = "localhost"
75
+ @actual_port = nil
76
+
77
+ if cli_url
78
+ @actual_host, @actual_port = parse_cli_url(cli_url)
79
+ @is_external_server = true
80
+ end
81
+
82
+ # Default use_logged_in_user based on github_token
83
+ use_logged_in_user = github_token ? false : true if use_logged_in_user.nil?
84
+
85
+ @options = ClientOptions.new(
86
+ cli_path: cli_path || "copilot",
87
+ cli_args: cli_args,
88
+ cwd: cwd || Dir.pwd,
89
+ port: port,
90
+ use_stdio: cli_url ? false : use_stdio,
91
+ cli_url: cli_url,
92
+ log_level: log_level,
93
+ auto_start: auto_start,
94
+ auto_restart: auto_restart,
95
+ env: env,
96
+ github_token: github_token,
97
+ use_logged_in_user: use_logged_in_user,
98
+ )
99
+
100
+ @process = nil
101
+ @stdin = nil
102
+ @stdout = nil
103
+ @stderr = nil
104
+ @rpc_client = nil
105
+ @socket = nil
106
+ @state = ConnectionState::DISCONNECTED
107
+
108
+ @sessions = {}
109
+ @sessions_lock = Mutex.new
110
+
111
+ @models_cache = nil
112
+ @models_cache_lock = Mutex.new
113
+
114
+ @lifecycle_handlers = []
115
+ @typed_lifecycle_handlers = {} # type => [handler]
116
+ @lifecycle_handlers_lock = Mutex.new
117
+
118
+ @stderr_thread = nil
119
+ end
120
+
121
+ # Start the CLI server and establish a connection.
122
+ #
123
+ # If connecting to an external server (via +cli_url+), only establishes the connection.
124
+ # Otherwise, spawns the CLI server process and then connects.
125
+ #
126
+ # @return [void]
127
+ # @raise [RuntimeError] if the server fails to start or the connection fails
128
+ def start
129
+ return if @state == ConnectionState::CONNECTED
130
+
131
+ @state = ConnectionState::CONNECTING
132
+ begin
133
+ start_cli_server unless @is_external_server
134
+ connect_to_server
135
+ verify_protocol_version
136
+ @state = ConnectionState::CONNECTED
137
+ rescue StandardError
138
+ @state = ConnectionState::ERROR
139
+ raise
140
+ end
141
+ end
142
+
143
+ # Stop the CLI server and close all active sessions.
144
+ #
145
+ # @return [Array<StopError>] errors encountered during cleanup (empty = success)
146
+ def stop
147
+ errors = []
148
+
149
+ # Destroy all active sessions
150
+ sessions_to_destroy = @sessions_lock.synchronize do
151
+ s = @sessions.values.dup
152
+ @sessions.clear
153
+ s
154
+ end
155
+
156
+ sessions_to_destroy.each do |session|
157
+ begin
158
+ session.destroy
159
+ rescue StandardError => e
160
+ errors << StopError.new(message: "Failed to destroy session #{session.session_id}: #{e.message}")
161
+ end
162
+ end
163
+
164
+ # Stop RPC client
165
+ if @rpc_client
166
+ @rpc_client.stop
167
+ @rpc_client = nil
168
+ end
169
+
170
+ # Clear models cache
171
+ @models_cache_lock.synchronize { @models_cache = nil }
172
+
173
+ # Close socket if TCP
174
+ if @socket
175
+ begin
176
+ @socket.close
177
+ rescue StandardError => e
178
+ errors << StopError.new(message: "Failed to close socket: #{e.message}")
179
+ end
180
+ @socket = nil
181
+ end
182
+
183
+ # Close stdio streams
184
+ [@stdin, @stdout].each do |io|
185
+ begin
186
+ io&.close
187
+ rescue StandardError
188
+ # ignore
189
+ end
190
+ end
191
+ @stdin = @stdout = nil
192
+
193
+ # Kill CLI process (only if we spawned it)
194
+ if @process && !@is_external_server
195
+ begin
196
+ Process.kill("TERM", @process)
197
+ Process.wait(@process)
198
+ rescue Errno::ESRCH, Errno::ECHILD
199
+ # Process already gone
200
+ rescue StandardError => e
201
+ errors << StopError.new(message: "Failed to kill CLI process: #{e.message}")
202
+ end
203
+ @process = nil
204
+ end
205
+
206
+ @stderr_thread&.join(2.0)
207
+ @stderr_thread = nil
208
+
209
+ @state = ConnectionState::DISCONNECTED
210
+ @actual_port = nil unless @is_external_server
211
+
212
+ errors
213
+ end
214
+
215
+ # Forcefully stop the CLI server without graceful cleanup.
216
+ #
217
+ # @return [void]
218
+ def force_stop
219
+ @sessions_lock.synchronize { @sessions.clear }
220
+
221
+ if @rpc_client
222
+ begin
223
+ @rpc_client.stop
224
+ rescue StandardError
225
+ # ignore
226
+ end
227
+ @rpc_client = nil
228
+ end
229
+
230
+ @models_cache_lock.synchronize { @models_cache = nil }
231
+
232
+ if @socket
233
+ begin
234
+ @socket.close
235
+ rescue StandardError
236
+ # ignore
237
+ end
238
+ @socket = nil
239
+ end
240
+
241
+ [@stdin, @stdout].each do |io|
242
+ begin
243
+ io&.close
244
+ rescue StandardError
245
+ # ignore
246
+ end
247
+ end
248
+ @stdin = @stdout = nil
249
+
250
+ if @process && !@is_external_server
251
+ begin
252
+ Process.kill("KILL", @process)
253
+ Process.wait(@process)
254
+ rescue StandardError
255
+ # ignore
256
+ end
257
+ @process = nil
258
+ end
259
+
260
+ @stderr_thread = nil
261
+ @state = ConnectionState::DISCONNECTED
262
+ @actual_port = nil unless @is_external_server
263
+ end
264
+
265
+ # Create a new conversation session.
266
+ #
267
+ # @param config [SessionConfig, Hash] session configuration
268
+ # @return [CopilotSession]
269
+ # @raise [RuntimeError] if not connected and auto_start is disabled
270
+ def create_session(**config)
271
+ ensure_connected!
272
+
273
+ payload = build_create_session_payload(config)
274
+ response = @rpc_client.request("session.create", payload)
275
+
276
+ session_id = response["sessionId"]
277
+ workspace_path = response["workspacePath"]
278
+
279
+ session = CopilotSession.new(session_id, @rpc_client, workspace_path)
280
+ session._register_tools(config[:tools])
281
+ session._register_permission_handler(config[:on_permission_request]) if config[:on_permission_request]
282
+ session._register_user_input_handler(config[:on_user_input_request]) if config[:on_user_input_request]
283
+ session._register_hooks(config[:hooks]) if config[:hooks]
284
+
285
+ @sessions_lock.synchronize { @sessions[session_id] = session }
286
+ session
287
+ end
288
+
289
+ # Resume an existing session.
290
+ #
291
+ # @param session_id [String] the session ID to resume
292
+ # @param config [ResumeSessionConfig, Hash] resume configuration
293
+ # @return [CopilotSession]
294
+ def resume_session(session_id, **config)
295
+ ensure_connected!
296
+
297
+ payload = build_resume_session_payload(session_id, config)
298
+ response = @rpc_client.request("session.resume", payload)
299
+
300
+ resumed_id = response["sessionId"]
301
+ workspace_path = response["workspacePath"]
302
+
303
+ session = CopilotSession.new(resumed_id, @rpc_client, workspace_path)
304
+ session._register_tools(config[:tools])
305
+ session._register_permission_handler(config[:on_permission_request]) if config[:on_permission_request]
306
+ session._register_user_input_handler(config[:on_user_input_request]) if config[:on_user_input_request]
307
+ session._register_hooks(config[:hooks]) if config[:hooks]
308
+
309
+ @sessions_lock.synchronize { @sessions[resumed_id] = session }
310
+ session
311
+ end
312
+
313
+ # Send a ping to verify connectivity.
314
+ #
315
+ # @param message [String, nil] optional message
316
+ # @return [PingResponse]
317
+ def ping(message = nil)
318
+ raise_not_connected! unless @rpc_client
319
+
320
+ result = @rpc_client.request("ping", { message: message })
321
+ PingResponse.from_hash(result)
322
+ end
323
+
324
+ # Get CLI status including version and protocol information.
325
+ #
326
+ # @return [GetStatusResponse]
327
+ def get_status
328
+ raise_not_connected! unless @rpc_client
329
+
330
+ result = @rpc_client.request("status.get", {})
331
+ GetStatusResponse.from_hash(result)
332
+ end
333
+
334
+ # Get current authentication status.
335
+ #
336
+ # @return [GetAuthStatusResponse]
337
+ def get_auth_status
338
+ raise_not_connected! unless @rpc_client
339
+
340
+ result = @rpc_client.request("auth.getStatus", {})
341
+ GetAuthStatusResponse.from_hash(result)
342
+ end
343
+
344
+ # List available models. Results are cached after the first call.
345
+ #
346
+ # @return [Array<ModelInfo>]
347
+ def list_models
348
+ raise_not_connected! unless @rpc_client
349
+
350
+ @models_cache_lock.synchronize do
351
+ return @models_cache.dup if @models_cache
352
+
353
+ response = @rpc_client.request("models.list", {})
354
+ models_data = response["models"] || []
355
+ @models_cache = models_data.map { |m| ModelInfo.from_hash(m) }
356
+ @models_cache.dup
357
+ end
358
+ end
359
+
360
+ # List all sessions known to the server.
361
+ #
362
+ # @return [Array<SessionMetadata>]
363
+ def list_sessions
364
+ raise_not_connected! unless @rpc_client
365
+
366
+ response = @rpc_client.request("session.list", {})
367
+ sessions_data = response["sessions"] || []
368
+ sessions_data.map { |s| SessionMetadata.from_hash(s) }
369
+ end
370
+
371
+ # Get the last (most recently updated) session ID.
372
+ #
373
+ # @return [String, nil]
374
+ def get_last_session_id
375
+ raise_not_connected! unless @rpc_client
376
+
377
+ response = @rpc_client.request("session.getLastId", {})
378
+ response["sessionId"]
379
+ end
380
+
381
+ # Delete a session permanently.
382
+ #
383
+ # @param session_id [String]
384
+ # @return [void]
385
+ # @raise [RuntimeError] if deletion fails
386
+ def delete_session(session_id)
387
+ raise_not_connected! unless @rpc_client
388
+
389
+ response = @rpc_client.request("session.delete", { sessionId: session_id })
390
+ unless response["success"]
391
+ error = response["error"] || "Unknown error"
392
+ raise "Failed to delete session #{session_id}: #{error}"
393
+ end
394
+
395
+ @sessions_lock.synchronize { @sessions.delete(session_id) }
396
+ end
397
+
398
+ # Get the foreground session ID (TUI+server mode).
399
+ #
400
+ # @return [String, nil]
401
+ def get_foreground_session_id
402
+ raise_not_connected! unless @rpc_client
403
+
404
+ response = @rpc_client.request("session.getForeground", {})
405
+ response["sessionId"]
406
+ end
407
+
408
+ # Set the foreground session (TUI+server mode).
409
+ #
410
+ # @param session_id [String]
411
+ # @return [void]
412
+ def set_foreground_session_id(session_id)
413
+ raise_not_connected! unless @rpc_client
414
+
415
+ response = @rpc_client.request("session.setForeground", { sessionId: session_id })
416
+ unless response["success"]
417
+ raise response["error"] || "Failed to set foreground session"
418
+ end
419
+ end
420
+
421
+ # Subscribe to session lifecycle events.
422
+ #
423
+ # @overload on(&handler)
424
+ # Subscribe to all lifecycle events.
425
+ # @yield [event] called for every lifecycle event
426
+ # @yieldparam event [SessionLifecycleEvent]
427
+ # @return [Proc] unsubscribe function
428
+ #
429
+ # @overload on(event_type, &handler)
430
+ # Subscribe to a specific lifecycle event type.
431
+ # @param event_type [String]
432
+ # @yield [event]
433
+ # @yieldparam event [SessionLifecycleEvent]
434
+ # @return [Proc] unsubscribe function
435
+ def on(event_type = nil, &handler)
436
+ raise ArgumentError, "Block required" unless handler
437
+
438
+ @lifecycle_handlers_lock.synchronize do
439
+ if event_type
440
+ (@typed_lifecycle_handlers[event_type] ||= []) << handler
441
+ else
442
+ @lifecycle_handlers << handler
443
+ end
444
+ end
445
+
446
+ -> {
447
+ @lifecycle_handlers_lock.synchronize do
448
+ if event_type
449
+ @typed_lifecycle_handlers[event_type]&.delete(handler)
450
+ else
451
+ @lifecycle_handlers.delete(handler)
452
+ end
453
+ end
454
+ }
455
+ end
456
+
457
+ private
458
+
459
+ def ensure_connected!
460
+ return if @rpc_client
461
+
462
+ if @options.auto_start
463
+ start
464
+ else
465
+ raise_not_connected!
466
+ end
467
+ end
468
+
469
+ def raise_not_connected!
470
+ raise "Client not connected. Call start() first."
471
+ end
472
+
473
+ def parse_cli_url(url)
474
+ clean = url.sub(%r{^https?://}, "")
475
+
476
+ if clean.match?(/\A\d+\z/)
477
+ port = clean.to_i
478
+ raise ArgumentError, "Invalid port in cli_url: #{url}" if port <= 0 || port > 65535
479
+
480
+ return ["localhost", port]
481
+ end
482
+
483
+ parts = clean.split(":")
484
+ raise ArgumentError, "Invalid cli_url format: #{url}" unless parts.length == 2
485
+
486
+ host = parts[0].empty? ? "localhost" : parts[0]
487
+ port = parts[1].to_i
488
+ raise ArgumentError, "Invalid port in cli_url: #{url}" if port <= 0 || port > 65535
489
+
490
+ [host, port]
491
+ end
492
+
493
+ def start_cli_server
494
+ cli_path = @options.cli_path
495
+
496
+ unless File.exist?(cli_path) || which(cli_path)
497
+ raise "Copilot CLI not found at #{cli_path}"
498
+ end
499
+
500
+ args = []
501
+ args.concat(@options.cli_args) if @options.cli_args
502
+ args.push("--headless", "--no-auto-update", "--log-level", @options.log_level)
503
+
504
+ # Auth flags
505
+ if @options.github_token
506
+ args.push("--auth-token-env", "COPILOT_SDK_AUTH_TOKEN")
507
+ end
508
+ unless @options.use_logged_in_user
509
+ args.push("--no-auto-login")
510
+ end
511
+
512
+ # Transport mode
513
+ if @options.use_stdio
514
+ args.push("--stdio")
515
+ elsif @options.port > 0
516
+ args.push("--port", @options.port.to_s)
517
+ end
518
+
519
+ # Build environment
520
+ spawn_env = @options.env ? @options.env.dup : ENV.to_h
521
+ spawn_env["COPILOT_SDK_AUTH_TOKEN"] = @options.github_token if @options.github_token
522
+
523
+ cmd = [cli_path] + args
524
+
525
+ if @options.use_stdio
526
+ @stdin, @stdout, @stderr, wait_thr = Open3.popen3(spawn_env, *cmd, chdir: @options.cwd)
527
+ @process = wait_thr.pid
528
+
529
+ # Forward stderr in background
530
+ @stderr_thread = Thread.new do
531
+ @stderr.each_line do |line|
532
+ $stderr.puts("[CLI subprocess] #{line.rstrip}") unless line.strip.empty?
533
+ end
534
+ rescue IOError
535
+ # stream closed
536
+ end
537
+ else
538
+ @stdin, @stdout, @stderr, wait_thr = Open3.popen3(spawn_env, *cmd, chdir: @options.cwd)
539
+ @process = wait_thr.pid
540
+
541
+ # Wait for port announcement
542
+ deadline = Time.now + 10
543
+ found = false
544
+ @stdout.each_line do |line|
545
+ if (match = line.match(/listening on port (\d+)/i))
546
+ @actual_port = match[1].to_i
547
+ found = true
548
+ break
549
+ end
550
+ break if Time.now > deadline
551
+ end
552
+
553
+ raise "Timeout waiting for CLI server to start" unless found
554
+ end
555
+ end
556
+
557
+ def connect_to_server
558
+ if @options.use_stdio
559
+ connect_via_stdio
560
+ else
561
+ connect_via_tcp
562
+ end
563
+ end
564
+
565
+ def connect_via_stdio
566
+ raise "CLI process not started" unless @stdout && @stdin
567
+
568
+ @rpc_client = JsonRpcClient.new(@stdout, @stdin)
569
+ attach_connection_handlers
570
+ @rpc_client.start
571
+ end
572
+
573
+ def connect_via_tcp
574
+ raise "Server port not available" unless @actual_port
575
+
576
+ @socket = TCPSocket.new(@actual_host, @actual_port)
577
+ @rpc_client = JsonRpcClient.new(@socket, @socket)
578
+ attach_connection_handlers
579
+ @rpc_client.start
580
+ end
581
+
582
+ def attach_connection_handlers
583
+ # Notifications
584
+ @rpc_client.on_notification do |method, params|
585
+ case method
586
+ when "session.event"
587
+ handle_session_event_notification(params)
588
+ when "session.lifecycle"
589
+ handle_session_lifecycle_notification(params)
590
+ end
591
+ end
592
+
593
+ # Server -> Client requests
594
+ @rpc_client.on_request("tool.call") do |params|
595
+ handle_tool_call_request(params)
596
+ end
597
+
598
+ @rpc_client.on_request("permission.request") do |params|
599
+ handle_permission_request(params)
600
+ end
601
+
602
+ @rpc_client.on_request("userInput.request") do |params|
603
+ handle_user_input_request(params)
604
+ end
605
+
606
+ @rpc_client.on_request("hooks.invoke") do |params|
607
+ handle_hooks_invoke(params)
608
+ end
609
+ end
610
+
611
+ def verify_protocol_version
612
+ expected = Copilot.sdk_protocol_version
613
+ result = ping
614
+ server_version = result.protocol_version
615
+
616
+ if server_version.nil?
617
+ raise "SDK protocol version mismatch: SDK expects version #{expected}, " \
618
+ "but server does not report a protocol version. " \
619
+ "Please update your server to ensure compatibility."
620
+ end
621
+
622
+ if server_version != expected
623
+ raise "SDK protocol version mismatch: SDK expects version #{expected}, " \
624
+ "but server reports version #{server_version}. " \
625
+ "Please update your SDK or server to ensure compatibility."
626
+ end
627
+ end
628
+
629
+ # ---- Notification handlers ----
630
+
631
+ def handle_session_event_notification(params)
632
+ session_id = params["sessionId"]
633
+ event_hash = params["event"]
634
+ return unless session_id && event_hash
635
+
636
+ event = SessionEvent.from_hash(event_hash)
637
+ session = @sessions_lock.synchronize { @sessions[session_id] }
638
+ session&._dispatch_event(event)
639
+ end
640
+
641
+ def handle_session_lifecycle_notification(params)
642
+ event = SessionLifecycleEvent.from_hash(params)
643
+
644
+ handlers = @lifecycle_handlers_lock.synchronize do
645
+ typed = @typed_lifecycle_handlers[event.type]&.dup || []
646
+ wildcard = @lifecycle_handlers.dup
647
+ typed + wildcard
648
+ end
649
+
650
+ handlers.each do |handler|
651
+ handler.call(event)
652
+ rescue StandardError
653
+ # ignore handler errors
654
+ end
655
+ end
656
+
657
+ # ---- Server request handlers ----
658
+
659
+ def handle_tool_call_request(params)
660
+ session_id = params["sessionId"]
661
+ tool_call_id = params["toolCallId"]
662
+ tool_name = params["toolName"]
663
+ arguments = params["arguments"]
664
+
665
+ raise "Invalid tool call payload" unless session_id && tool_call_id && tool_name
666
+
667
+ session = @sessions_lock.synchronize { @sessions[session_id] }
668
+ raise "Unknown session #{session_id}" unless session
669
+
670
+ handler = session._get_tool_handler(tool_name)
671
+ unless handler
672
+ return { result: build_unsupported_tool_result(tool_name) }
673
+ end
674
+
675
+ execute_tool_call(session_id, tool_call_id, tool_name, arguments, handler)
676
+ end
677
+
678
+ def execute_tool_call(session_id, tool_call_id, tool_name, arguments, handler)
679
+ invocation = ToolInvocation.new(
680
+ session_id: session_id,
681
+ tool_call_id: tool_call_id,
682
+ tool_name: tool_name,
683
+ arguments: arguments
684
+ )
685
+
686
+ begin
687
+ result = handler.call(arguments, invocation)
688
+ { result: Copilot.normalize_tool_result(result) }
689
+ rescue StandardError => e
690
+ {
691
+ result: {
692
+ textResultForLlm: "Invoking this tool produced an error. Detailed information is not available.",
693
+ resultType: ToolResultType::FAILURE,
694
+ error: e.message,
695
+ toolTelemetry: {},
696
+ }
697
+ }
698
+ end
699
+ end
700
+
701
+ def handle_permission_request(params)
702
+ session_id = params["sessionId"]
703
+ permission_request = params["permissionRequest"]
704
+
705
+ raise "Invalid permission request payload" unless session_id && permission_request
706
+
707
+ session = @sessions_lock.synchronize { @sessions[session_id] }
708
+ raise "Session not found: #{session_id}" unless session
709
+
710
+ begin
711
+ result = session._handle_permission_request(permission_request)
712
+ { result: result }
713
+ rescue StandardError
714
+ { result: { kind: PermissionKind::DENIED_NO_APPROVAL } }
715
+ end
716
+ end
717
+
718
+ def handle_user_input_request(params)
719
+ session_id = params["sessionId"]
720
+ question = params["question"]
721
+
722
+ raise "Invalid user input request payload" unless session_id && question
723
+
724
+ session = @sessions_lock.synchronize { @sessions[session_id] }
725
+ raise "Session not found: #{session_id}" unless session
726
+
727
+ result = session._handle_user_input_request(params)
728
+ { answer: result[:answer], wasFreeform: result[:wasFreeform] }
729
+ end
730
+
731
+ def handle_hooks_invoke(params)
732
+ session_id = params["sessionId"]
733
+ hook_type = params["hookType"]
734
+ input_data = params["input"]
735
+
736
+ raise "Invalid hooks invoke payload" unless session_id && hook_type
737
+
738
+ session = @sessions_lock.synchronize { @sessions[session_id] }
739
+ raise "Session not found: #{session_id}" unless session
740
+
741
+ output = session._handle_hooks_invoke(hook_type, input_data)
742
+ { output: output }
743
+ end
744
+
745
+ # ---- Payload builders ----
746
+
747
+ def build_create_session_payload(config)
748
+ payload = {}
749
+
750
+ payload[:model] = config[:model] if config[:model]
751
+ payload[:sessionId] = config[:session_id] if config[:session_id]
752
+ payload[:reasoningEffort] = config[:reasoning_effort] if config[:reasoning_effort]
753
+ payload[:configDir] = config[:config_dir] if config[:config_dir]
754
+
755
+ if config[:tools]
756
+ payload[:tools] = config[:tools].map(&:to_wire)
757
+ end
758
+
759
+ payload[:systemMessage] = convert_system_message(config[:system_message]) if config[:system_message]
760
+ payload[:availableTools] = config[:available_tools] if config[:available_tools]
761
+ payload[:excludedTools] = config[:excluded_tools] if config[:excluded_tools]
762
+ payload[:requestPermission] = true if config[:on_permission_request]
763
+ payload[:requestUserInput] = true if config[:on_user_input_request]
764
+ payload[:hooks] = true if config[:hooks]&.respond_to?(:any_handler?) && config[:hooks].any_handler?
765
+ payload[:workingDirectory] = config[:working_directory] if config[:working_directory]
766
+ payload[:streaming] = config[:streaming] unless config[:streaming].nil?
767
+
768
+ if config[:provider]
769
+ payload[:provider] = config[:provider].respond_to?(:to_wire) ? config[:provider].to_wire : config[:provider]
770
+ end
771
+
772
+ payload[:mcpServers] = config[:mcp_servers] if config[:mcp_servers]
773
+
774
+ if config[:custom_agents]
775
+ payload[:customAgents] = config[:custom_agents].map { |a|
776
+ a.respond_to?(:to_wire) ? a.to_wire : a
777
+ }
778
+ end
779
+
780
+ payload[:skillDirectories] = config[:skill_directories] if config[:skill_directories]
781
+ payload[:disabledSkills] = config[:disabled_skills] if config[:disabled_skills]
782
+
783
+ if config[:infinite_sessions]
784
+ is_cfg = config[:infinite_sessions]
785
+ payload[:infiniteSessions] = is_cfg.respond_to?(:to_wire) ? is_cfg.to_wire : is_cfg
786
+ end
787
+
788
+ payload
789
+ end
790
+
791
+ def build_resume_session_payload(session_id, config)
792
+ payload = build_create_session_payload(config)
793
+ payload[:sessionId] = session_id
794
+ payload[:disableResume] = config[:disable_resume] if config[:disable_resume]
795
+ payload
796
+ end
797
+
798
+ def convert_system_message(msg)
799
+ return msg if msg.is_a?(Hash)
800
+
801
+ msg.respond_to?(:to_h) ? msg.to_h : msg
802
+ end
803
+
804
+ def build_unsupported_tool_result(tool_name)
805
+ {
806
+ textResultForLlm: "Tool '#{tool_name}' is not supported by this client instance.",
807
+ resultType: ToolResultType::FAILURE,
808
+ error: "tool '#{tool_name}' not supported",
809
+ toolTelemetry: {},
810
+ }
811
+ end
812
+
813
+ # Check if a command exists on PATH.
814
+ def which(cmd)
815
+ exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [""]
816
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |path|
817
+ exts.each do |ext|
818
+ exe = File.join(path, "#{cmd}#{ext}")
819
+ return exe if File.executable?(exe) && !File.directory?(exe)
820
+ end
821
+ end
822
+ nil
823
+ end
824
+ end
825
+ end