Class: Harnex::Adapters::CodexAppServer

Inherits:
Base
  • Object
show all
Defined in:
lib/harnex/adapters/codex_appserver.rb

Overview

Codex app-server adapter — JSON-RPC over stdio.

Talks to a spawned codex app-server subprocess by writing newline-delimited JSON-RPC messages on stdin and reading responses + notifications from stdout. Replaces the pane-scraping heuristics in Adapters::Codex (legacy, kept behind --legacy-pty).

Constant Summary collapse

CLIENT_TITLE =
"harnex"
CLIENT_NAME =
"harnex"
OPT_OUT_NOTIFICATIONS =
%w[
  item/agentMessage/delta
  item/reasoning/summaryTextDelta
  item/reasoning/summaryPartAdded
  item/reasoning/textDelta
].freeze
REQUEST_METHODS =
%w[
  initialize thread/start turn/start turn/interrupt thread/resume
].freeze
NOTIFICATION_METHODS =
%w[
  thread/started turn/started turn/completed
  item/started item/completed
  thread/status/changed thread/tokenUsage/updated
  thread/compacted account/rateLimits/updated
  error
].freeze
EVENTS =
%w[task_complete turn_started item_completed disconnected].freeze
STOP_TERM_GRACE_SECONDS =
0.5
STOP_KILL_GRACE_SECONDS =
1.0
APPROVAL_RESPONSES =

Server→client approval requests harnex auto-approves so dispatched codex workers can run autonomously. Codex sends these via JSON-RPC when its sandbox/approval policy needs a client decision; without a handler the client returns -32601 and codex blocks the operation. Permissions / user-input / dynamic-tool / auth-refresh requests have richer response shapes and are deliberately not auto-handled — they fall through to -32601 until a use case appears.

{
  "applyPatchApproval"                    => { decision: "approved" },
  "execCommandApproval"                   => { decision: "approved" },
  "item/commandExecution/requestApproval" => { decision: "accept" },
  "item/fileChange/requestApproval"       => { decision: "accept" }
}.freeze

Constants inherited from Base

Base::AGENT_VERSION_TIMEOUT_SECONDS, Base::PROMPT_PREFIXES

Instance Attribute Summary collapse

Attributes inherited from Base

#key

Instance Method Summary collapse

Methods inherited from Base

#agent_version, #infer_repo_path, #parse_session_summary, #send_wait_seconds, #wait_for_sendable, #wait_for_sendable_state?

Constructor Details

#initialize(extra_args = []) ⇒ CodexAppServer

Returns a new instance of CodexAppServer.



56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/harnex/adapters/codex_appserver.rb', line 56

def initialize(extra_args = [])
  super("codex", extra_args)
  reject_unsupported_codex_flags!
  @initial_prompt = extract_initial_prompt(extra_args)
  @client = nil
  @thread_id = nil
  @current_turn_id = nil
  @state = :disconnected
  @last_completed_at = nil
  @notification_handler = nil
  @disconnect_handler = nil
end

Instance Attribute Details

#current_turn_idObject (readonly)

Returns the value of attribute current_turn_id.



54
55
56
# File 'lib/harnex/adapters/codex_appserver.rb', line 54

def current_turn_id
  @current_turn_id
end

#initial_promptObject (readonly)

Returns the value of attribute initial_prompt.



54
55
56
# File 'lib/harnex/adapters/codex_appserver.rb', line 54

def initial_prompt
  @initial_prompt
end

#last_completed_atObject (readonly)

Returns the value of attribute last_completed_at.



54
55
56
# File 'lib/harnex/adapters/codex_appserver.rb', line 54

def last_completed_at
  @last_completed_at
end

#thread_idObject (readonly)

Returns the value of attribute thread_id.



54
55
56
# File 'lib/harnex/adapters/codex_appserver.rb', line 54

def thread_id
  @thread_id
end

Instance Method Details

#base_commandObject



89
90
91
# File 'lib/harnex/adapters/codex_appserver.rb', line 89

def base_command
  ["codex", "app-server"]
end

#build_commandObject

The harnex-context entry (set by --context) is delivered via JSON-RPC turn/start, not as a CLI argument — codex app-server rejects positional input and would exit immediately. Operator- supplied codex flags (passed via harnex run codex -- ...) are appended so e.g. -c sandbox_mode=danger-full-access works.



98
99
100
# File 'lib/harnex/adapters/codex_appserver.rb', line 98

def build_command
  base_command + cli_extra_args
end

#build_send_payload(text:, submit:, enter_only:, screen_text:, force: false) ⇒ Object

Raises:

  • (ArgumentError)


123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/harnex/adapters/codex_appserver.rb', line 123

def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
  state = input_state(nil)
  if !force && submit && !enter_only && state[:input_ready] != true
    raise ArgumentError, blocked_message(state, enter_only: enter_only)
  end
  raise ArgumentError, "Codex app-server cannot stage input without submitting it" unless submit || enter_only
  raise ArgumentError, "Codex app-server does not support submit-only input" if enter_only

  {
    dispatch: { prompt: text.to_s },
    input_state: state,
    force: force
  }
end

#closeObject



251
252
253
254
255
256
257
# File 'lib/harnex/adapters/codex_appserver.rb', line 251

def close
  return unless @client

  @client.close
  @client = nil
  @state = :disconnected
end

#context_telemetry_sourceObject



85
86
87
# File 'lib/harnex/adapters/codex_appserver.rb', line 85

def context_telemetry_source
  "codex_thread_token_usage_last"
end

#context_telemetry_supported?Boolean

Returns:

  • (Boolean)


81
82
83
# File 'lib/harnex/adapters/codex_appserver.rb', line 81

def context_telemetry_supported?
  true
end

#describeObject



102
103
104
105
106
107
108
109
# File 'lib/harnex/adapters/codex_appserver.rb', line 102

def describe
  {
    transport: transport,
    request_methods: REQUEST_METHODS,
    notification_methods: NOTIFICATION_METHODS,
    events: EVENTS
  }
end

#dispatch(prompt:, model: nil, effort: nil) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/harnex/adapters/codex_appserver.rb', line 177

def dispatch(prompt:, model: nil, effort: nil)
  ensure_open!
  ensure_thread!
  params = {
    threadId: @thread_id,
    input: [{ type: "text", text: prompt.to_s }]
  }
  params[:model] = model if model
  params[:effort] = effort if effort

  result = @client.request("turn/start", params)
  @current_turn_id = result.dig("turn", "id")
  @state = :busy
  @current_turn_id
end

#handle_server_request(method, _params) ⇒ Object

Auto-approve known approval-style requests so dispatched workers can run without a human-in-the-loop. Returns the response body to serialize as JSON-RPC result, or nil to fall through to -32601.



173
174
175
# File 'lib/harnex/adapters/codex_appserver.rb', line 173

def handle_server_request(method, _params)
  APPROVAL_RESPONSES[method]
end

#inject_exit(_writer, **_kwargs) ⇒ Object

No-op: closing the subprocess is handled via #close.



139
140
141
# File 'lib/harnex/adapters/codex_appserver.rb', line 139

def inject_exit(_writer, **_kwargs)
  nil
end

#input_state(_screen_text = nil) ⇒ Object

Override: state is RPC-driven, screen text is ignored.



116
117
118
119
120
121
# File 'lib/harnex/adapters/codex_appserver.rb', line 116

def input_state(_screen_text = nil)
  {
    state: @state.to_s,
    input_ready: @state == :prompt
  }
end

#interrupt(turn_id: nil) ⇒ Object



193
194
195
196
197
198
199
# File 'lib/harnex/adapters/codex_appserver.rb', line 193

def interrupt(turn_id: nil)
  ensure_open!
  target = turn_id || @current_turn_id
  return nil if target.nil?

  @client.request("turn/interrupt", { threadId: @thread_id, turnId: target })
end

#on_disconnect(&block) ⇒ Object



147
148
149
# File 'lib/harnex/adapters/codex_appserver.rb', line 147

def on_disconnect(&block)
  @disconnect_handler = block
end

#on_notification(&block) ⇒ Object



143
144
145
# File 'lib/harnex/adapters/codex_appserver.rb', line 143

def on_notification(&block)
  @notification_handler = block
end

#pidObject



266
267
268
# File 'lib/harnex/adapters/codex_appserver.rb', line 266

def pid
  @client&.pid
end

#providerObject



73
74
75
# File 'lib/harnex/adapters/codex_appserver.rb', line 73

def provider
  "openai"
end

#resume(thread_id:) ⇒ Object



201
202
203
204
205
206
207
# File 'lib/harnex/adapters/codex_appserver.rb', line 201

def resume(thread_id:)
  ensure_open!
  result = @client.request("thread/resume", { threadId: thread_id })
  @thread_id = thread_id
  @state = :prompt
  result
end

#start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil) ⇒ Object

Start the JSON-RPC client. In production, spawns the codex subprocess. In tests, callers may pass pre-built IO objects.



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/harnex/adapters/codex_appserver.rb', line 153

def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
  if read_io && write_io
    @client = Harnex::Codex::AppServer::Client.new(read_io: read_io, write_io: write_io, pid: pid)
  else
    spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
    @client = Harnex::Codex::AppServer::Client.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
  end

  @client.on_notification { |msg| handle_notification(msg) }
  @client.on_request { |method, params| handle_server_request(method, params) }
  @client.on_disconnect { |err| handle_disconnect(err) }
  @client.start
  perform_handshake
  @state = :prompt
  self
end

#stateObject



111
112
113
# File 'lib/harnex/adapters/codex_appserver.rb', line 111

def state
  @state
end

#switch_deployment(deployment_config:, term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS) ⇒ Object

Plan 30 Phase 2 — subprocess-restart primitive for deployment fallback. Stops the current JSON-RPC subprocess, spawns a new one against the supplied deployment_config, and resumes the same threadId so conversation state carries across. Thin orchestrator: counter snapshots, the fallback_triggered event, and any Session-level signaling land in plan 30 Phases 3–4 alongside the per-arm telemetry split. CLI flags land in Phase 5.

deployment_config: { command: [...argv], env: ..., cwd: nil }



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
# File 'lib/harnex/adapters/codex_appserver.rb', line 218

def switch_deployment(deployment_config:,
                      term_grace_seconds: STOP_TERM_GRACE_SECONDS,
                      kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
  raise "codex_appserver: client not started" unless @client
  raise "codex_appserver: no thread to resume" if @thread_id.nil? || @thread_id.to_s.empty?

  prior_thread_id = @thread_id
  in_flight =
    if @current_turn_id
      { threadId: prior_thread_id, turnId: @current_turn_id }
    end

  @client.stop_for_fallback(
    in_flight_turn: in_flight,
    term_grace_seconds: term_grace_seconds,
    kill_grace_seconds: kill_grace_seconds
  )

  @client = Harnex::Codex::AppServer::Client.spawn_with_fallback(
    prior_thread_id: prior_thread_id,
    deployment_config: deployment_config,
    handshake_params: handshake_initialize_params,
    notification_handler: ->(msg) { handle_notification(msg) },
    request_handler: ->(method, params) { handle_server_request(method, params) },
    disconnect_handler: ->(err) { handle_disconnect(err) }
  )

  @thread_id = prior_thread_id
  @current_turn_id = nil
  @state = :prompt
  self
end

#terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS) ⇒ Object



259
260
261
262
263
264
# File 'lib/harnex/adapters/codex_appserver.rb', line 259

def terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
  @client&.terminate_process(
    term_grace_seconds: term_grace_seconds,
    kill_grace_seconds: kill_grace_seconds
  )
end

#transportObject



69
70
71
# File 'lib/harnex/adapters/codex_appserver.rb', line 69

def transport
  :stdio_jsonrpc
end

#usage_telemetry_supported?Boolean

Returns:

  • (Boolean)


77
78
79
# File 'lib/harnex/adapters/codex_appserver.rb', line 77

def usage_telemetry_supported?
  true
end