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



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

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.



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

def build_command
  base_command + cli_extra_args
end

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

Raises:

  • (ArgumentError)


111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/harnex/adapters/codex_appserver.rb', line 111

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



239
240
241
242
243
244
245
# File 'lib/harnex/adapters/codex_appserver.rb', line 239

def close
  return unless @client

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

#describeObject



90
91
92
93
94
95
96
97
# File 'lib/harnex/adapters/codex_appserver.rb', line 90

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

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



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/harnex/adapters/codex_appserver.rb', line 165

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.



161
162
163
# File 'lib/harnex/adapters/codex_appserver.rb', line 161

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

#inject_exit(_writer, **_kwargs) ⇒ Object

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



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

def inject_exit(_writer, **_kwargs)
  nil
end

#input_state(_screen_text = nil) ⇒ Object

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



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

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

#interrupt(turn_id: nil) ⇒ Object



181
182
183
184
185
186
187
# File 'lib/harnex/adapters/codex_appserver.rb', line 181

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



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

def on_disconnect(&block)
  @disconnect_handler = block
end

#on_notification(&block) ⇒ Object



131
132
133
# File 'lib/harnex/adapters/codex_appserver.rb', line 131

def on_notification(&block)
  @notification_handler = block
end

#pidObject



254
255
256
# File 'lib/harnex/adapters/codex_appserver.rb', line 254

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



189
190
191
192
193
194
195
# File 'lib/harnex/adapters/codex_appserver.rb', line 189

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.



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

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



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

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 }



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/harnex/adapters/codex_appserver.rb', line 206

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



247
248
249
250
251
252
# File 'lib/harnex/adapters/codex_appserver.rb', line 247

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