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.
- checksums.yaml +7 -0
- data/README.md +301 -0
- data/lib/copilot/client.rb +825 -0
- data/lib/copilot/define_tool.rb +82 -0
- data/lib/copilot/json_rpc_client.rb +326 -0
- data/lib/copilot/sdk_protocol_version.rb +16 -0
- data/lib/copilot/session.rb +299 -0
- data/lib/copilot/types.rb +484 -0
- data/lib/copilot/version.rb +7 -0
- data/lib/copilot.rb +28 -0
- metadata +58 -0
|
@@ -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
|