Class: Copilot::CopilotClient

Inherits:
Object
  • Object
show all
Defined in:
lib/copilot/client.rb

Overview

Main client for interacting with the Copilot CLI.

The CopilotClient manages the connection to the Copilot CLI server and provides methods to create and manage conversation sessions. It can either spawn a CLI server process or connect to an existing server.

Examples:

client = Copilot::CopilotClient.new(cli_path: "/usr/local/bin/copilot")
client.start

session = client.create_session(model: "gpt-4")
response = session.send_and_wait(prompt: "Hello!")
puts response&.data&.dig("content")

session.destroy
client.stop

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(cli_path: nil, cli_args: [], cwd: nil, port: 0, use_stdio: true, cli_url: nil, log_level: "info", auto_start: true, auto_restart: true, env: nil, github_token: nil, use_logged_in_user: nil, on_get_trace_context: nil) ⇒ CopilotClient

Create a new CopilotClient.

Parameters:

  • cli_path (String, nil) (defaults to: nil)

    path to the Copilot CLI executable

  • cli_args (Array<String>) (defaults to: [])

    extra arguments for the CLI

  • cwd (String, nil) (defaults to: nil)

    working directory for the CLI process

  • port (Integer) (defaults to: 0)

    TCP port (0 = random); ignored in stdio mode

  • use_stdio (Boolean) (defaults to: true)

    use stdio transport (default: true)

  • cli_url (String, nil) (defaults to: nil)

    URL of an existing server ("host:port")

  • log_level (String) (defaults to: "info")

    log level for the CLI ("info", "debug", etc.)

  • auto_start (Boolean) (defaults to: true)

    auto-start on first use (default: true)

  • auto_restart (Boolean) (defaults to: true)

    auto-restart if the server crashes (default: true)

  • env (Hash, nil) (defaults to: nil)

    environment variables for the CLI process

  • github_token (String, nil) (defaults to: nil)

    GitHub token for authentication

  • use_logged_in_user (Boolean, nil) (defaults to: nil)

    use logged-in user auth (default: true unless github_token)

Raises:

  • (ArgumentError)

    if mutually exclusive options are provided



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/copilot/client.rb', line 48

def initialize(
  cli_path: nil,
  cli_args: [],
  cwd: nil,
  port: 0,
  use_stdio: true,
  cli_url: nil,
  log_level: "info",
  auto_start: true,
  auto_restart: true,
  env: nil,
  github_token: nil,
  use_logged_in_user: nil,
  on_get_trace_context: nil
)
  # Validate mutually exclusive options
  if cli_url && (use_stdio == false || cli_path)
    # cli_url with explicit use_stdio=false is fine; but cli_url with cli_path is not
  end
  if cli_url && cli_path
    raise ArgumentError, "cli_url is mutually exclusive with cli_path"
  end
  if cli_url && (github_token || !use_logged_in_user.nil?)
    raise ArgumentError,
          "github_token and use_logged_in_user cannot be used with cli_url " \
          "(external server manages its own auth)"
  end

  @is_external_server = false
  @actual_host = "localhost"
  @actual_port = nil

  if cli_url
    @actual_host, @actual_port = parse_cli_url(cli_url)
    @is_external_server = true
  end

  # Default use_logged_in_user based on github_token
  use_logged_in_user = github_token ? false : true if use_logged_in_user.nil?

  @options = ClientOptions.new(
    cli_path: cli_path || "copilot",
    cli_args: cli_args,
    cwd: cwd || Dir.pwd,
    port: port,
    use_stdio: cli_url ? false : use_stdio,
    cli_url: cli_url,
    log_level: log_level,
    auto_start: auto_start,
    auto_restart: auto_restart,
    env: env,
    github_token: github_token,
    use_logged_in_user: use_logged_in_user,
    on_get_trace_context: on_get_trace_context,
  )

  @process    = nil
  @stdin      = nil
  @stdout     = nil
  @stderr     = nil
  @rpc_client = nil
  @socket     = nil
  @state      = ConnectionState::DISCONNECTED

  @sessions      = {}
  @sessions_lock = Mutex.new

  @models_cache      = nil
  @models_cache_lock = Mutex.new

  @lifecycle_handlers       = []
  @typed_lifecycle_handlers = {} # type => [handler]
  @lifecycle_handlers_lock  = Mutex.new

  @stderr_thread = nil
end

Instance Attribute Details

#stateString (readonly)

Returns the current connection state.

Returns:

  • (String)

    the current connection state



30
31
32
# File 'lib/copilot/client.rb', line 30

def state
  @state
end

Instance Method Details

#create_session(**config) ⇒ CopilotSession

Create a new conversation session.

Parameters:

Returns:

Raises:

  • (RuntimeError)

    if not connected and auto_start is disabled



274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/copilot/client.rb', line 274

def create_session(**config)
  ensure_connected!

  payload = build_create_session_payload(config)
  response = @rpc_client.request("session.create", payload)

  session_id = response["sessionId"]
  workspace_path = response["workspacePath"]

  session = CopilotSession.new(session_id, @rpc_client, workspace_path)
  session._register_tools(config[:tools])
  session._register_permission_handler(config[:on_permission_request]) if config[:on_permission_request]
  session._register_user_input_handler(config[:on_user_input_request]) if config[:on_user_input_request]
  session._register_hooks(config[:hooks]) if config[:hooks]
  session._register_exit_plan_mode_handler(config[:on_exit_plan_mode]) if config[:on_exit_plan_mode]
  if @options.on_get_trace_context
    session._register_trace_context_provider(@options.on_get_trace_context)
  end

  @sessions_lock.synchronize { @sessions[session_id] = session }
  session
end

#delete_session(session_id) ⇒ void

This method returns an undefined value.

Delete a session permanently.

Parameters:

  • session_id (String)

Raises:

  • (RuntimeError)

    if deletion fails



408
409
410
411
412
413
414
415
416
417
418
# File 'lib/copilot/client.rb', line 408

def delete_session(session_id)
  raise_not_connected! unless @rpc_client

  response = @rpc_client.request("session.delete", { sessionId: session_id })
  unless response["success"]
    error = response["error"] || "Unknown error"
    raise "Failed to delete session #{session_id}: #{error}"
  end

  @sessions_lock.synchronize { @sessions.delete(session_id) }
end

#force_stopvoid

This method returns an undefined value.

Forcefully stop the CLI server without graceful cleanup.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'lib/copilot/client.rb', line 222

def force_stop
  @sessions_lock.synchronize { @sessions.clear }

  if @rpc_client
    begin
      @rpc_client.stop
    rescue StandardError
      # ignore
    end
    @rpc_client = nil
  end

  @models_cache_lock.synchronize { @models_cache = nil }

  if @socket
    begin
      @socket.close
    rescue StandardError
      # ignore
    end
    @socket = nil
  end

  [@stdin, @stdout].each do |io|
    begin
      io&.close
    rescue StandardError
      # ignore
    end
  end
  @stdin = @stdout = nil

  if @process && !@is_external_server
    begin
      Process.kill("KILL", @process)
      Process.wait(@process)
    rescue StandardError
      # ignore
    end
    @process = nil
  end

  @stderr_thread = nil
  @state = ConnectionState::DISCONNECTED
  @actual_port = nil unless @is_external_server
end

#get_auth_statusGetAuthStatusResponse

Get current authentication status.



349
350
351
352
353
354
# File 'lib/copilot/client.rb', line 349

def get_auth_status
  raise_not_connected! unless @rpc_client

  result = @rpc_client.request("auth.getStatus", {})
  GetAuthStatusResponse.from_hash(result)
end

#get_foreground_session_idString?

Get the foreground session ID (TUI+server mode).

Returns:

  • (String, nil)


423
424
425
426
427
428
# File 'lib/copilot/client.rb', line 423

def get_foreground_session_id
  raise_not_connected! unless @rpc_client

  response = @rpc_client.request("session.getForeground", {})
  response["sessionId"]
end

#get_last_session_idString?

Get the last (most recently updated) session ID.

Returns:

  • (String, nil)


386
387
388
389
390
391
# File 'lib/copilot/client.rb', line 386

def get_last_session_id
  raise_not_connected! unless @rpc_client

  response = @rpc_client.request("session.getLastId", {})
  response["sessionId"]
end

#get_session_metadata(session_id) ⇒ Hash

Get metadata for a specific session.

Parameters:

  • session_id (String)

Returns:

  • (Hash)

    session metadata



397
398
399
400
401
# File 'lib/copilot/client.rb', line 397

def (session_id)
  raise_not_connected! unless @rpc_client

  @rpc_client.request("session.getMetadata", { sessionId: session_id })
end

#get_statusGetStatusResponse

Get CLI status including version and protocol information.

Returns:



339
340
341
342
343
344
# File 'lib/copilot/client.rb', line 339

def get_status
  raise_not_connected! unless @rpc_client

  result = @rpc_client.request("status.get", {})
  GetStatusResponse.from_hash(result)
end

#list_modelsArray<ModelInfo>

List available models. Results are cached after the first call.

Returns:



359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/copilot/client.rb', line 359

def list_models
  raise_not_connected! unless @rpc_client

  @models_cache_lock.synchronize do
    return @models_cache.dup if @models_cache

    response = @rpc_client.request("models.list", {})
    models_data = response["models"] || []
    @models_cache = models_data.map { |m| ModelInfo.from_hash(m) }
    @models_cache.dup
  end
end

#list_sessionsArray<SessionMetadata>

List all sessions known to the server.

Returns:



375
376
377
378
379
380
381
# File 'lib/copilot/client.rb', line 375

def list_sessions
  raise_not_connected! unless @rpc_client

  response = @rpc_client.request("session.list", {})
  sessions_data = response["sessions"] || []
  sessions_data.map { |s| SessionMetadata.from_hash(s) }
end

#on {|event| ... } ⇒ Proc #on(event_type) {|event| ... } ⇒ Proc

Subscribe to session lifecycle events.

Overloads:

  • #on {|event| ... } ⇒ Proc

    Subscribe to all lifecycle events.

    Yields:

    • (event)

      called for every lifecycle event

    Yield Parameters:

    Returns:

    • (Proc)

      unsubscribe function

  • #on(event_type) {|event| ... } ⇒ Proc

    Subscribe to a specific lifecycle event type.

    Parameters:

    • event_type (String)

    Yields:

    • (event)

    Yield Parameters:

    Returns:

    • (Proc)

      unsubscribe function

Raises:

  • (ArgumentError)


477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
# File 'lib/copilot/client.rb', line 477

def on(event_type = nil, &handler)
  raise ArgumentError, "Block required" unless handler

  @lifecycle_handlers_lock.synchronize do
    if event_type
      (@typed_lifecycle_handlers[event_type] ||= []) << handler
    else
      @lifecycle_handlers << handler
    end
  end

  -> {
    @lifecycle_handlers_lock.synchronize do
      if event_type
        @typed_lifecycle_handlers[event_type]&.delete(handler)
      else
        @lifecycle_handlers.delete(handler)
      end
    end
  }
end

#ping(message = nil) ⇒ PingResponse

Send a ping to verify connectivity.

Parameters:

  • message (String, nil) (defaults to: nil)

    optional message

Returns:



329
330
331
332
333
334
# File 'lib/copilot/client.rb', line 329

def ping(message = nil)
  raise_not_connected! unless @rpc_client

  result = @rpc_client.request("ping", { message: message })
  PingResponse.from_hash(result)
end

#resume_session(session_id, **config) ⇒ CopilotSession

Resume an existing session.

Parameters:

  • session_id (String)

    the session ID to resume

  • config (ResumeSessionConfig, Hash)

    resume configuration

Returns:



302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/copilot/client.rb', line 302

def resume_session(session_id, **config)
  ensure_connected!

  payload = build_resume_session_payload(session_id, config)
  response = @rpc_client.request("session.resume", payload)

  resumed_id = response["sessionId"]
  workspace_path = response["workspacePath"]

  session = CopilotSession.new(resumed_id, @rpc_client, workspace_path)
  session._register_tools(config[:tools])
  session._register_permission_handler(config[:on_permission_request]) if config[:on_permission_request]
  session._register_user_input_handler(config[:on_user_input_request]) if config[:on_user_input_request]
  session._register_hooks(config[:hooks]) if config[:hooks]
  session._register_exit_plan_mode_handler(config[:on_exit_plan_mode]) if config[:on_exit_plan_mode]
  if @options.on_get_trace_context
    session._register_trace_context_provider(@options.on_get_trace_context)
  end

  @sessions_lock.synchronize { @sessions[resumed_id] = session }
  session
end

#set_foreground_session_id(session_id) ⇒ void

This method returns an undefined value.

Set the foreground session (TUI+server mode).

Parameters:

  • session_id (String)


434
435
436
437
438
439
440
441
# File 'lib/copilot/client.rb', line 434

def set_foreground_session_id(session_id)
  raise_not_connected! unless @rpc_client

  response = @rpc_client.request("session.setForeground", { sessionId: session_id })
  unless response["success"]
    raise response["error"] || "Failed to set foreground session"
  end
end

#set_session_fs_provider(initial_cwd: nil, session_state_path: nil, conventions: nil) ⇒ void

This method returns an undefined value.

Sets the session filesystem provider configuration.

Parameters:

  • initial_cwd (String, nil) (defaults to: nil)

    Initial working directory

  • session_state_path (String, nil) (defaults to: nil)

    Path for session state persistence

  • conventions (String, nil) (defaults to: nil)

    Filesystem conventions ("posix" or "windows"). Required by the CLI; defaults to the host platform's conventions when omitted.



450
451
452
453
454
455
456
457
458
459
460
461
# File 'lib/copilot/client.rb', line 450

def set_session_fs_provider(initial_cwd: nil, session_state_path: nil, conventions: nil)
  raise_not_connected! unless @rpc_client

  conventions ||= (RUBY_PLATFORM =~ /mswin|mingw|cygwin/ ? "windows" : "posix")

  params = {}
  params[:initialCwd] = initial_cwd if initial_cwd
  params[:sessionStatePath] = session_state_path if session_state_path
  params[:conventions] = conventions

  @rpc_client.request("sessionFs.setProvider", params)
end

#startvoid

This method returns an undefined value.

Start the CLI server and establish a connection.

If connecting to an external server (via cli_url), only establishes the connection. Otherwise, spawns the CLI server process and then connects.

Raises:

  • (RuntimeError)

    if the server fails to start or the connection fails



132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/copilot/client.rb', line 132

def start
  return if @state == ConnectionState::CONNECTED

  @state = ConnectionState::CONNECTING
  begin
    start_cli_server unless @is_external_server
    connect_to_server
    verify_protocol_version
    @state = ConnectionState::CONNECTED
  rescue StandardError
    @state = ConnectionState::ERROR
    raise
  end
end

#stopArray<StopError>

Stop the CLI server and close all active sessions.

Returns:

  • (Array<StopError>)

    errors encountered during cleanup (empty = success)



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/copilot/client.rb', line 150

def stop
  errors = []

  # Destroy all active sessions
  sessions_to_destroy = @sessions_lock.synchronize do
    s = @sessions.values.dup
    @sessions.clear
    s
  end

  sessions_to_destroy.each do |session|
    begin
      session.destroy
    rescue StandardError => e
      errors << StopError.new(message: "Failed to destroy session #{session.session_id}: #{e.message}")
    end
  end

  # Stop RPC client
  if @rpc_client
    @rpc_client.stop
    @rpc_client = nil
  end

  # Clear models cache
  @models_cache_lock.synchronize { @models_cache = nil }

  # Close socket if TCP
  if @socket
    begin
      @socket.close
    rescue StandardError => e
      errors << StopError.new(message: "Failed to close socket: #{e.message}")
    end
    @socket = nil
  end

  # Close stdio streams
  [@stdin, @stdout].each do |io|
    begin
      io&.close
    rescue StandardError
      # ignore
    end
  end
  @stdin = @stdout = nil

  # Kill CLI process (only if we spawned it)
  if @process && !@is_external_server
    begin
      Process.kill("TERM", @process)
      Process.wait(@process)
    rescue Errno::ESRCH, Errno::ECHILD
      # Process already gone
    rescue StandardError => e
      errors << StopError.new(message: "Failed to kill CLI process: #{e.message}")
    end
    @process = nil
  end

  @stderr_thread&.join(2.0)
  @stderr_thread = nil

  @state = ConnectionState::DISCONNECTED
  @actual_port = nil unless @is_external_server

  errors
end