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) ⇒ 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



46
47
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
# File 'lib/copilot/client.rb', line 46

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
)
  # 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,
  )

  @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



28
29
30
# File 'lib/copilot/client.rb', line 28

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



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/copilot/client.rb', line 270

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]

  @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



386
387
388
389
390
391
392
393
394
395
396
# File 'lib/copilot/client.rb', line 386

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.



218
219
220
221
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
# File 'lib/copilot/client.rb', line 218

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.



337
338
339
340
341
342
# File 'lib/copilot/client.rb', line 337

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)


401
402
403
404
405
406
# File 'lib/copilot/client.rb', line 401

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)


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

def get_last_session_id
  raise_not_connected! unless @rpc_client

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

#get_statusGetStatusResponse

Get CLI status including version and protocol information.

Returns:



327
328
329
330
331
332
# File 'lib/copilot/client.rb', line 327

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:



347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/copilot/client.rb', line 347

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:



363
364
365
366
367
368
369
# File 'lib/copilot/client.rb', line 363

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)


435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/copilot/client.rb', line 435

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:



317
318
319
320
321
322
# File 'lib/copilot/client.rb', line 317

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:



294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/copilot/client.rb', line 294

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]

  @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)


412
413
414
415
416
417
418
419
# File 'lib/copilot/client.rb', line 412

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

#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



128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/copilot/client.rb', line 128

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)



146
147
148
149
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
# File 'lib/copilot/client.rb', line 146

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