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
68
# 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_model = 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



105
106
107
# File 'lib/harnex/adapters/codex_appserver.rb', line 105

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.



114
115
116
# File 'lib/harnex/adapters/codex_appserver.rb', line 114

def build_command
  base_command + cli_extra_args
end

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

Raises:

  • (ArgumentError)


139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/harnex/adapters/codex_appserver.rb', line 139

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



268
269
270
271
272
273
274
# File 'lib/harnex/adapters/codex_appserver.rb', line 268

def close
  return unless @client

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

#context_telemetry_sourceObject



101
102
103
# File 'lib/harnex/adapters/codex_appserver.rb', line 101

def context_telemetry_source
  "codex_thread_token_usage_last"
end

#context_telemetry_supported?Boolean

Returns:

  • (Boolean)


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

def context_telemetry_supported?
  true
end

#current_modelObject

Effective model reported by the app-server's thread/start or thread/resume response (schema-required field). Feeds Session#summary_model so price-table cost can resolve without the caller passing --meta '{"model": ...}'.



93
94
95
# File 'lib/harnex/adapters/codex_appserver.rb', line 93

def current_model
  @current_model
end

#describeObject



118
119
120
121
122
123
124
125
# File 'lib/harnex/adapters/codex_appserver.rb', line 118

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

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



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/harnex/adapters/codex_appserver.rb', line 193

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.



189
190
191
# File 'lib/harnex/adapters/codex_appserver.rb', line 189

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

#inject_exit(_writer, **_kwargs) ⇒ Object

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



155
156
157
# File 'lib/harnex/adapters/codex_appserver.rb', line 155

def inject_exit(_writer, **_kwargs)
  nil
end

#input_state(_screen_text = nil) ⇒ Object

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



132
133
134
135
136
137
# File 'lib/harnex/adapters/codex_appserver.rb', line 132

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

#interrupt(turn_id: nil) ⇒ Object



209
210
211
212
213
214
215
# File 'lib/harnex/adapters/codex_appserver.rb', line 209

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



163
164
165
# File 'lib/harnex/adapters/codex_appserver.rb', line 163

def on_disconnect(&block)
  @disconnect_handler = block
end

#on_notification(&block) ⇒ Object



159
160
161
# File 'lib/harnex/adapters/codex_appserver.rb', line 159

def on_notification(&block)
  @notification_handler = block
end

#pidObject



283
284
285
# File 'lib/harnex/adapters/codex_appserver.rb', line 283

def pid
  @client&.pid
end

#providerObject



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

def provider
  "openai"
end

#resume(thread_id:) ⇒ Object



217
218
219
220
221
222
223
224
# File 'lib/harnex/adapters/codex_appserver.rb', line 217

def resume(thread_id:)
  ensure_open!
  result = @client.request("thread/resume", { threadId: thread_id })
  @thread_id = thread_id
  @current_model = extract_model(result) || @current_model
  @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.



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/harnex/adapters/codex_appserver.rb', line 169

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



127
128
129
# File 'lib/harnex/adapters/codex_appserver.rb', line 127

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 }



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

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



276
277
278
279
280
281
# File 'lib/harnex/adapters/codex_appserver.rb', line 276

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



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

def transport
  :stdio_jsonrpc
end

#usage_input_includes_cached?Boolean

thread/tokenUsage/updated totals report cachedInputTokens as a subset of inputTokens (verified against a live captured row: input + output == total exactly, cached < input).

Returns:

  • (Boolean)


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

def usage_input_includes_cached?
  true
end

#usage_telemetry_supported?Boolean

Returns:

  • (Boolean)


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

def usage_telemetry_supported?
  true
end