Class: Ask::CodingProviders::Codex::AppServerClient

Inherits:
Object
  • Object
show all
Defined in:
lib/ask/coding_providers/codex/app_server_client.rb

Overview

JSON-RPC 2.0 client for the Codex app-server (stdio transport).

Protocol: https://learn.chatgpt.com/docs/app-server

Spawns codex app-server as a subprocess and communicates over stdio using newline-delimited JSON.

Constant Summary collapse

CLI_PATHS =
[
  -> { ENV["CODEX_CLI_PATH"] },
  -> { find_in_path("codex") },
].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(cwd: ".", cli_path: nil, request_timeout: 30.0, model: nil, model_provider: nil) ⇒ AppServerClient

Returns a new instance of AppServerClient.



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 23

def initialize(cwd: ".", cli_path: nil, request_timeout: 30.0, model: nil, model_provider: nil)
  @cwd = cwd
  @cli_path = cli_path || self.class.resolve_cli_path
  @request_timeout = request_timeout
  @model = model
  @model_provider = model_provider
  @extra_args = build_extra_args
  @mutex = Mutex.new
  @stdin = nil
  @event_handlers = []
  @pending = {}
  @next_id = 0
  @started = false
  @stdout_queue = Queue.new
  @initialized = false
end

Instance Attribute Details

#cli_pathObject (readonly)

Returns the value of attribute cli_path.



21
22
23
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 21

def cli_path
  @cli_path
end

Class Method Details

.find_in_path(executable) ⇒ Object



251
252
253
254
255
256
257
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 251

def self.find_in_path(executable)
  ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
    path = File.join(dir, executable)
    return path if File.exist?(path)
  end
  nil
end

.resolve_cli_pathObject

Raises:



185
186
187
188
189
190
191
192
193
194
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 185

def self.resolve_cli_path
  CLI_PATHS.each do |resolver|
    path = resolver.call
    return path if path && File.exist?(path)
  end
  # Check the npm global install path
  npm_path = File.expand_path("~/.asdf/installs/nodejs/*/lib/node_modules/@openai/codex/bin/codex.js")
  Dir[npm_path].each { |p| return p if File.exist?(p) }
  raise Error, "Cannot find Codex CLI. Set CODEX_CLI_PATH or install Codex."
end

Instance Method Details

#initialize!Object

Perform the JSON-RPC initialize handshake. Must be called before any other request.



94
95
96
97
98
99
100
101
102
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 94

def initialize!
  result = request("initialize", {
    protocolVersion: "0.1.0",
    clientInfo: { name: "askoda", version: "0.1.0" },
    capabilities: {}
  })
  @initialized = true
  result
end

#model_listObject



150
151
152
153
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 150

def model_list
  ensure_initialized
  request("model/list", {})
end

#on_notification(&handler) ⇒ Object



88
89
90
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 88

def on_notification(&handler)
  @mutex.synchronize { @event_handlers << handler }
end

#read_workspace_stateObject



145
146
147
148
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 145

def read_workspace_state
  ensure_initialized
  request("config/read", {})
end

#request(method, params = nil, timeout: nil) ⇒ Object



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 160

def request(method, params = nil, timeout: nil)
  ensure_running
  future = { done: false, result: nil, error: nil, condition: ConditionVariable.new }

  @mutex.synchronize do
    @next_id += 1
    @pending[@next_id] = future
    write_line({ jsonrpc: "2.0", id: @next_id, method: method, params: params }.compact)
  end

  timeout ||= @request_timeout
  deadline = Time.now + timeout

  @mutex.synchronize do
    until future[:done]
      remaining = deadline - Time.now
      raise TimeoutError, "Request timed out after #{timeout}s" if remaining <= 0
      future[:condition].wait(@mutex, remaining)
    end
  end

  raise future[:error] if future[:error]
  future[:result]
end

#respond(request_id, result) ⇒ Object



155
156
157
158
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 155

def respond(request_id, result)
  ensure_initialized
  @mutex.synchronize { write_line({ id: request_id, result: result }) }
end

#running?Boolean

Returns:

  • (Boolean)


84
85
86
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 84

def running?
  @started && @wait_thr&.alive?
end

#send_initializedObject



104
105
106
107
108
109
110
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 104

def send_initialized
  return unless @initialized
  # The 'initialized' notification is a JSON-RPC 2.0 notification (no id)
  @mutex.synchronize do
    write_line({ jsonrpc: "2.0", method: "initialized", params: {} })
  end
end

#startObject



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 40

def start
  @mutex.synchronize do
    return if @started
    @stdin, stdout, stderr, @wait_thr = Open3.popen3(@cli_path, "app-server", "--stdio", *@extra_args, chdir: @cwd)

    @stdout_thread = Thread.new(stdout) do |io|
      io.each_line do |line|
        line = line.strip
        @stdout_queue << line unless line.empty?
      end
      @stdout_queue << nil
    end

    # Drain stderr to avoid deadlocks
    Thread.new(stderr) { |io| io.each_line { |_| } }

    @dispatcher = Thread.new do
      while (line = @stdout_queue.pop)
        begin
          handle_message(JSON.parse(line))
        rescue JSON::ParserError
        end
      end
    rescue => e
    end

    @started = true
  end
end

#stopObject



70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 70

def stop
  @mutex.synchronize do
    return unless @started
    @started = false
    @stdin&.close rescue nil
    @stdout_thread&.join(3) rescue nil
    Process.kill("TERM", @wait_thr.pid) rescue nil
    @wait_thr&.join(5) rescue nil
    @stdin = nil
    @pending.each_value { |f| f[:error] = Error.new("process exited"); f[:done] = true; f[:condition].signal }
    @pending.clear
  end
end

#thread_list(limit: 20) ⇒ Object



140
141
142
143
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 140

def thread_list(limit: 20)
  ensure_initialized
  request("thread/list", { limit: limit })
end

#thread_resume(thread_id) ⇒ Object



125
126
127
128
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 125

def thread_resume(thread_id)
  ensure_initialized
  request("thread/resume", { threadId: thread_id })
end

#thread_start(cwd: nil) ⇒ Object

-- High-level API --



114
115
116
117
118
119
120
121
122
123
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 114

def thread_start(cwd: nil)
  ensure_initialized
  params = {
    cwd: cwd || @cwd,
    approvalPolicy: "never",
    sandbox: "read-only"
  }
  result = request("thread/start", params)
  result&.dig("thread") || result&.dig(:thread) || {}
end

#turn_start(thread_id, input) ⇒ Object



130
131
132
133
134
135
136
137
138
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 130

def turn_start(thread_id, input)
  ensure_initialized
  input_items = input.is_a?(Array) ? input : [{ type: "text", text: input.to_s }]
  request("turn/start", {
    threadId: thread_id,
    input: input_items,
    cwd: @cwd
  })
end