Class: Ask::CodingProviders::Codex::AppServerClient
- Inherits:
-
Object
- Object
- Ask::CodingProviders::Codex::AppServerClient
- 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
-
#cli_path ⇒ Object
readonly
Returns the value of attribute cli_path.
Class Method Summary collapse
Instance Method Summary collapse
-
#initialize(cwd: ".", cli_path: nil, request_timeout: 30.0, model: nil, model_provider: nil) ⇒ AppServerClient
constructor
A new instance of AppServerClient.
-
#initialize! ⇒ Object
Perform the JSON-RPC initialize handshake.
- #model_list ⇒ Object
- #on_notification(&handler) ⇒ Object
- #read_workspace_state ⇒ Object
- #request(method, params = nil, timeout: nil) ⇒ Object
- #respond(request_id, result) ⇒ Object
- #running? ⇒ Boolean
- #send_initialized ⇒ Object
- #start ⇒ Object
- #stop ⇒ Object
- #thread_list(limit: 20) ⇒ Object
- #thread_resume(thread_id) ⇒ Object
-
#thread_start(cwd: nil) ⇒ Object
-- High-level API --.
- #turn_start(thread_id, input) ⇒ Object
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_path ⇒ Object (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_path ⇒ Object
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.("~/.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_list ⇒ Object
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_state ⇒ Object
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
84 85 86 |
# File 'lib/ask/coding_providers/codex/app_server_client.rb', line 84 def running? @started && @wait_thr&.alive? end |
#send_initialized ⇒ Object
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 |
#start ⇒ Object
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 (JSON.parse(line)) rescue JSON::ParserError end end rescue => e end @started = true end end |
#stop ⇒ Object
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 |