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).

Defined Under Namespace

Classes: JsonRpcClient

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
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: "approved" },
  "item/fileChange/requestApproval"       => { decision: "accept" }
}.freeze

Constants inherited from Base

Base::PROMPT_PREFIXES

Instance Attribute Summary collapse

Attributes inherited from Base

#key

Instance Method Summary collapse

Methods inherited from Base

#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.



53
54
55
56
57
58
59
60
61
62
63
# File 'lib/harnex/adapters/codex_appserver.rb', line 53

def initialize(extra_args = [])
  super("codex", extra_args)
  @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.



51
52
53
# File 'lib/harnex/adapters/codex_appserver.rb', line 51

def current_turn_id
  @current_turn_id
end

#initial_promptObject (readonly)

Returns the value of attribute initial_prompt.



51
52
53
# File 'lib/harnex/adapters/codex_appserver.rb', line 51

def initial_prompt
  @initial_prompt
end

#last_completed_atObject (readonly)

Returns the value of attribute last_completed_at.



51
52
53
# File 'lib/harnex/adapters/codex_appserver.rb', line 51

def last_completed_at
  @last_completed_at
end

#thread_idObject (readonly)

Returns the value of attribute thread_id.



51
52
53
# File 'lib/harnex/adapters/codex_appserver.rb', line 51

def thread_id
  @thread_id
end

Instance Method Details

#base_commandObject



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

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.



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

def build_command
  base_command + cli_extra_args
end

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

Raises:

  • (ArgumentError)


103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/harnex/adapters/codex_appserver.rb', line 103

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



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

def close
  return unless @client

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

#describeObject



82
83
84
85
86
87
88
89
# File 'lib/harnex/adapters/codex_appserver.rb', line 82

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

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



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/harnex/adapters/codex_appserver.rb', line 157

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["turnId"] || result["turn_id"] || result["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.



153
154
155
# File 'lib/harnex/adapters/codex_appserver.rb', line 153

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

#inject_exit(_writer, **_kwargs) ⇒ Object

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



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

def inject_exit(_writer, **_kwargs)
  nil
end

#input_state(_screen_text = nil) ⇒ Object

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



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

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

#interrupt(turn_id: nil) ⇒ Object



173
174
175
176
177
178
179
# File 'lib/harnex/adapters/codex_appserver.rb', line 173

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



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

def on_disconnect(&block)
  @disconnect_handler = block
end

#on_notification(&block) ⇒ Object



123
124
125
# File 'lib/harnex/adapters/codex_appserver.rb', line 123

def on_notification(&block)
  @notification_handler = block
end

#pidObject



197
198
199
# File 'lib/harnex/adapters/codex_appserver.rb', line 197

def pid
  @client&.pid
end

#resume(thread_id:) ⇒ Object



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

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.



133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/harnex/adapters/codex_appserver.rb', line 133

def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
  if read_io && write_io
    @client = JsonRpcClient.new(read_io: read_io, write_io: write_io, pid: pid)
  else
    spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
    @client = JsonRpcClient.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



91
92
93
# File 'lib/harnex/adapters/codex_appserver.rb', line 91

def state
  @state
end

#transportObject



65
66
67
# File 'lib/harnex/adapters/codex_appserver.rb', line 65

def transport
  :stdio_jsonrpc
end