harnex 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4d7c194ba2ba10ee075e6e53aa3eaf291cbfc08eca1d89cd291955834230c01
4
- data.tar.gz: e3bafc087ae74a484be3ee121cce0d8f351c6b381ce244d5da032e48c85da4db
3
+ metadata.gz: 0003e780e17784a566b09e37d39ca760531d78ef8b912093944bb7f1eb65c134
4
+ data.tar.gz: 58f1ec4d5919c19a7b445900aff28143636a530470e7d04bf1962532661d308e
5
5
  SHA512:
6
- metadata.gz: e3737c2aa49fb223c75cfc0d997a52bb72029b4c1a7a11affdd05a16c685e4edc42384ff0cfc03e63e4b4abcc9228c7f1d37c408c074538392c75d13be4e1e5e
7
- data.tar.gz: 0b096fb9d95f22b812fc43fc69b4f8546e090d76c669383b8d71b34a048aa8eed46510f7ce9be27e0da3eab449a610c13fd6a338d8e1c202649d235df7052154
6
+ metadata.gz: f979f0c86edf2ac6a694b20e75392ed23059a43a38ea4de7a3620c3270589d08bacd880e5c4ec660859b4430528bb8ae56b90399397010fdb87e06c9ac926847
7
+ data.tar.gz: f109e68a8165cef5627ffa5a2a0ca1afb8e8ace1809591bdefdce6a7e7e8259c2db5e8764bbf06ec191ef7197beef45653377f7e5f0742535ffe5402f28ce12d
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0 — 2026-05-06
4
+
5
+ ### Architectural pivot: Codex on JSON-RPC
6
+
7
+ harnex now speaks `codex app-server` JSON-RPC over stdio for the
8
+ Codex adapter. Pane-scraping is retired for Codex. Closed by
9
+ construction:
10
+
11
+ - #22 (Codex side; `--watch --stall-after` still applies to
12
+ claude/generic)
13
+ - #24 (disconnect detection — `error` notifications and JSON-RPC
14
+ error responses replace screen-text regex)
15
+ - #25 (first-class completion signal — `turn/completed` is it)
16
+
17
+ ### New
18
+
19
+ - `harnex wait --until task_complete` — block until a turn completes.
20
+ Example: `harnex wait --id cx-i-242 --until task_complete`.
21
+ Adapter-agnostic; tails the events JSONL.
22
+ - `harnex status --json` includes `last_completed_at`, `model`,
23
+ `effort`, `auto_disconnects`.
24
+ - `harnex doctor` preflight checks Codex CLI ≥ 0.128.0.
25
+ - `Adapter#transport` and `Adapter#describe` extension points so
26
+ callers can introspect adapter contracts. Default is
27
+ `:pty` for backward compatibility.
28
+
29
+ ### Migration
30
+
31
+ - Codex CLI ≥ 0.128.0 required.
32
+ - Existing `harnex run codex ...` invocations work unchanged.
33
+ - Emergency fallback: `harnex run codex --legacy-pty ...` (the
34
+ pre-0.6.0 PTY adapter). Deprecated; will be removed in 0.7.0.
35
+
36
+ ### Cross-repo
37
+
38
+ - Resolves holm #201 from the harnex side. holm #271 (substrate v2
39
+ meta) tracks the broader pivot.
data/TECHNICAL.md CHANGED
@@ -321,7 +321,25 @@ The adapter reads the screen and returns a state hash:
321
321
  | `confirmation` | `false` | Modal confirmation |
322
322
  | `unknown` | `nil` | Can't determine |
323
323
 
324
- ### Codex Adapter
324
+ ### Codex Adapter (default — JSON-RPC `app-server`)
325
+
326
+ - `transport :stdio_jsonrpc` — speaks JSON-RPC 2.0 over the
327
+ subprocess's stdin/stdout, one JSON object per line.
328
+ - Launches `codex app-server` (Codex CLI ≥ 0.128.0; verify with
329
+ `harnex doctor`).
330
+ - Notifications (`turn/started`, `turn/completed`, `item/completed`,
331
+ `error`, `thread/compacted`, …) fan into the events log.
332
+ `task_complete` is the harnex-side event for `turn/completed`.
333
+ - Disconnect is detected from JSON-RPC error responses, subprocess
334
+ EOF, parse errors, or a server `error` notification — no screen
335
+ regex required.
336
+ - Synthesized transcript: `item/completed` text payloads stream to
337
+ both the output log and STDOUT so tmux/pane workflows continue to
338
+ work without a real PTY.
339
+ - See `docs/codex-appserver.md` for the full mapping table and
340
+ troubleshooting.
341
+
342
+ #### Codex Adapter (legacy PTY — `--legacy-pty`, removal in 0.7.0)
325
343
 
326
344
  - Launches with `--no-alt-screen` for inline screen output
327
345
  - Detects prompt by looking for `›` prefix in recent lines
@@ -20,6 +20,16 @@ module Harnex
20
20
  @extra_args = extra_args.dup
21
21
  end
22
22
 
23
+ # Default transport. Adapters speaking JSON-RPC override to
24
+ # :stdio_jsonrpc; Session#run uses this to pick the I/O path.
25
+ def transport
26
+ :pty
27
+ end
28
+
29
+ def describe
30
+ { transport: transport }
31
+ end
32
+
23
33
  def build_command
24
34
  base_command + @extra_args
25
35
  end
@@ -39,6 +49,10 @@ module Harnex
39
49
  }
40
50
  end
41
51
 
52
+ def parse_session_summary(_transcript_tail)
53
+ {}
54
+ end
55
+
42
56
  def send_wait_seconds(submit:, enter_only:)
43
57
  0.0
44
58
  end
@@ -56,6 +56,25 @@ module Harnex
56
56
  end
57
57
  end
58
58
 
59
+ def parse_session_summary(transcript_tail)
60
+ summary = empty_session_summary
61
+ text = transcript_tail.to_s
62
+
63
+ if (match = text.match(/Token usage:\s+total=([\d,]+)\s+input=([\d,]+)(?:\s+\(\+\s+([\d,]+)\s+cached\))?\s+output=([\d,]+)(?:\s+\(reasoning\s+([\d,]+)\))?/))
64
+ summary[:total_tokens] = parse_token_count(match[1])
65
+ summary[:input_tokens] = parse_token_count(match[2])
66
+ summary[:cached_tokens] = parse_token_count(match[3])
67
+ summary[:output_tokens] = parse_token_count(match[4])
68
+ summary[:reasoning_tokens] = parse_token_count(match[5])
69
+ end
70
+
71
+ if (match = text.match(/codex resume\s+([0-9a-f-]{36})/))
72
+ summary[:agent_session_id] = match[1]
73
+ end
74
+
75
+ summary
76
+ end
77
+
59
78
  def send_wait_seconds(submit:, enter_only:)
60
79
  return 0.0 unless submit
61
80
  return 0.0 if enter_only
@@ -101,6 +120,23 @@ module Harnex
101
120
 
102
121
  protected
103
122
 
123
+ def empty_session_summary
124
+ {
125
+ input_tokens: nil,
126
+ output_tokens: nil,
127
+ reasoning_tokens: nil,
128
+ cached_tokens: nil,
129
+ total_tokens: nil,
130
+ agent_session_id: nil
131
+ }
132
+ end
133
+
134
+ def parse_token_count(value)
135
+ return nil if value.nil?
136
+
137
+ Integer(value.delete(","))
138
+ end
139
+
104
140
  def submit_delay_ms(text)
105
141
  extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
106
142
  SUBMIT_DELAY_MS + extra
@@ -0,0 +1,390 @@
1
+ require "json"
2
+ require "open3"
3
+
4
+ module Harnex
5
+ module Adapters
6
+ # Codex `app-server` adapter — JSON-RPC over stdio.
7
+ #
8
+ # Talks to a spawned `codex app-server` subprocess by writing
9
+ # newline-delimited JSON-RPC messages on stdin and reading
10
+ # responses + notifications from stdout. Replaces the pane-scraping
11
+ # heuristics in `Adapters::Codex` (legacy, kept behind --legacy-pty).
12
+ class CodexAppServer < Base
13
+ CLIENT_TITLE = "harnex"
14
+ CLIENT_NAME = "harnex"
15
+
16
+ OPT_OUT_NOTIFICATIONS = %w[
17
+ item/agentMessage/delta
18
+ item/reasoning/summaryTextDelta
19
+ item/reasoning/summaryPartAdded
20
+ item/reasoning/textDelta
21
+ ].freeze
22
+
23
+ REQUEST_METHODS = %w[
24
+ initialize thread/start turn/start turn/interrupt thread/resume
25
+ ].freeze
26
+
27
+ NOTIFICATION_METHODS = %w[
28
+ thread/started turn/started turn/completed
29
+ item/started item/completed
30
+ thread/status/changed thread/tokenUsage/updated
31
+ thread/compacted account/rateLimits/updated
32
+ error
33
+ ].freeze
34
+
35
+ EVENTS = %w[task_complete turn_started item_completed disconnected].freeze
36
+
37
+ attr_reader :thread_id, :current_turn_id, :last_completed_at
38
+
39
+ def initialize(extra_args = [])
40
+ super("codex", extra_args)
41
+ @client = nil
42
+ @thread_id = nil
43
+ @current_turn_id = nil
44
+ @state = :disconnected
45
+ @last_completed_at = nil
46
+ @notification_handler = nil
47
+ @disconnect_handler = nil
48
+ end
49
+
50
+ def transport
51
+ :stdio_jsonrpc
52
+ end
53
+
54
+ def base_command
55
+ ["codex", "app-server"]
56
+ end
57
+
58
+ def describe
59
+ {
60
+ transport: transport,
61
+ request_methods: REQUEST_METHODS,
62
+ notification_methods: NOTIFICATION_METHODS,
63
+ events: EVENTS
64
+ }
65
+ end
66
+
67
+ def state
68
+ @state
69
+ end
70
+
71
+ # Override: state is RPC-driven, screen text is ignored.
72
+ def input_state(_screen_text = nil)
73
+ {
74
+ state: @state.to_s,
75
+ input_ready: @state == :prompt
76
+ }
77
+ end
78
+
79
+ # The screen-based send path is not used for stdio_jsonrpc.
80
+ # Session#run_jsonrpc routes through #dispatch instead.
81
+ def build_send_payload(*)
82
+ raise NotImplementedError, "codex_appserver uses #dispatch, not screen-based send"
83
+ end
84
+
85
+ # No-op: closing the subprocess is handled via #close.
86
+ def inject_exit(_writer, **_kwargs)
87
+ nil
88
+ end
89
+
90
+ def on_notification(&block)
91
+ @notification_handler = block
92
+ end
93
+
94
+ def on_disconnect(&block)
95
+ @disconnect_handler = block
96
+ end
97
+
98
+ # Start the JSON-RPC client. In production, spawns the codex
99
+ # subprocess. In tests, callers may pass pre-built IO objects.
100
+ def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
101
+ if read_io && write_io
102
+ @client = JsonRpcClient.new(read_io: read_io, write_io: write_io, pid: pid)
103
+ else
104
+ spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
105
+ @client = JsonRpcClient.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
106
+ end
107
+
108
+ @client.on_notification { |msg| handle_notification(msg) }
109
+ @client.on_disconnect { |err| handle_disconnect(err) }
110
+ @client.start
111
+ perform_handshake
112
+ @state = :prompt
113
+ self
114
+ end
115
+
116
+ def dispatch(prompt:, model: nil, effort: nil)
117
+ ensure_open!
118
+ ensure_thread!
119
+ params = {
120
+ threadId: @thread_id,
121
+ input: { content: [{ type: "text", text: prompt.to_s }] }
122
+ }
123
+ params[:model] = model if model
124
+ params[:effort] = effort if effort
125
+
126
+ result = @client.request("turn/start", params)
127
+ @current_turn_id = result["turnId"] || result["turn_id"] || result["id"]
128
+ @state = :busy
129
+ @current_turn_id
130
+ end
131
+
132
+ def interrupt(turn_id: nil)
133
+ ensure_open!
134
+ target = turn_id || @current_turn_id
135
+ return nil if target.nil?
136
+
137
+ @client.request("turn/interrupt", { threadId: @thread_id, turnId: target })
138
+ end
139
+
140
+ def resume(thread_id:)
141
+ ensure_open!
142
+ result = @client.request("thread/resume", { threadId: thread_id })
143
+ @thread_id = thread_id
144
+ @state = :prompt
145
+ result
146
+ end
147
+
148
+ def close
149
+ return unless @client
150
+
151
+ @client.close
152
+ @client = nil
153
+ @state = :disconnected
154
+ end
155
+
156
+ def pid
157
+ @client&.pid
158
+ end
159
+
160
+ private
161
+
162
+ def ensure_open!
163
+ raise "codex_appserver: client not started" unless @client
164
+ raise "codex_appserver: disconnected" if @state == :disconnected
165
+ end
166
+
167
+ def ensure_thread!
168
+ return if @thread_id
169
+
170
+ result = @client.request("thread/start", {})
171
+ @thread_id = result["threadId"] || result["thread_id"]
172
+ end
173
+
174
+ def perform_handshake
175
+ @client.request("initialize", {
176
+ clientInfo: {
177
+ title: CLIENT_TITLE,
178
+ name: CLIENT_NAME,
179
+ version: Harnex::VERSION
180
+ },
181
+ capabilities: {
182
+ experimentalApi: false,
183
+ optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
184
+ }
185
+ })
186
+ @client.notify("initialized", {})
187
+ end
188
+
189
+ def handle_notification(message)
190
+ method = message["method"]
191
+ params = message["params"] || {}
192
+
193
+ case method
194
+ when "thread/started"
195
+ @thread_id ||= params["threadId"] || params["thread_id"]
196
+ when "turn/started"
197
+ @current_turn_id = params["turnId"] || params["turn_id"]
198
+ @state = :busy
199
+ when "turn/completed"
200
+ @last_completed_at = Time.now
201
+ @current_turn_id = nil
202
+ @state = :prompt
203
+ when "error"
204
+ @state = :disconnected
205
+ end
206
+
207
+ @notification_handler&.call(message)
208
+ end
209
+
210
+ def handle_disconnect(error)
211
+ @state = :disconnected
212
+ @disconnect_handler&.call(error)
213
+ end
214
+
215
+ def spawn_subprocess(env, cwd)
216
+ spawn_env = env || {}
217
+ opts = {}
218
+ opts[:chdir] = cwd if cwd
219
+ stdin_io, stdout_io, _stderr_io, wait_thr =
220
+ Open3.popen3(spawn_env, *build_command, **opts)
221
+ [wait_thr.pid, stdin_io, stdout_io]
222
+ end
223
+
224
+ # Minimal JSON-RPC 2.0 client. One JSON object per line.
225
+ # Responses keyed by id; everything else is a notification.
226
+ class JsonRpcClient
227
+ attr_reader :pid
228
+
229
+ def initialize(read_io:, write_io:, pid: nil)
230
+ @read_io = read_io
231
+ @write_io = write_io
232
+ @pid = pid
233
+ @next_id = 1
234
+ @pending = {}
235
+ @id_mutex = Mutex.new
236
+ @write_mutex = Mutex.new
237
+ @notification_handler = nil
238
+ @disconnect_handler = nil
239
+ @disconnect_signaled = false
240
+ @closed = false
241
+ @reader_thread = nil
242
+ end
243
+
244
+ def on_notification(&block)
245
+ @notification_handler = block
246
+ end
247
+
248
+ def on_disconnect(&block)
249
+ @disconnect_handler = block
250
+ end
251
+
252
+ def start
253
+ @reader_thread = Thread.new { read_loop }
254
+ end
255
+
256
+ def request(method, params = {})
257
+ raise "codex_appserver client is closed" if @closed
258
+
259
+ queue = Queue.new
260
+ id = @id_mutex.synchronize do
261
+ assigned = @next_id
262
+ @next_id += 1
263
+ @pending[assigned] = queue
264
+ assigned
265
+ end
266
+
267
+ write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
268
+ result = queue.pop
269
+ raise result if result.is_a?(Exception)
270
+
271
+ result
272
+ end
273
+
274
+ def notify(method, params = {})
275
+ return if @closed
276
+
277
+ write_line({ jsonrpc: "2.0", method: method, params: params })
278
+ end
279
+
280
+ def close
281
+ return if @closed
282
+
283
+ @closed = true
284
+
285
+ @id_mutex.synchronize do
286
+ @pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
287
+ @pending.clear
288
+ end
289
+
290
+ begin
291
+ @write_io.close unless @write_io.closed?
292
+ rescue IOError
293
+ nil
294
+ end
295
+
296
+ if @pid && process_alive?(@pid)
297
+ sleep 0.05
298
+ begin
299
+ Process.kill("TERM", @pid)
300
+ rescue Errno::ESRCH
301
+ nil
302
+ end
303
+ end
304
+
305
+ @reader_thread&.join(2)
306
+ end
307
+
308
+ private
309
+
310
+ def write_line(message)
311
+ @write_mutex.synchronize do
312
+ @write_io.write(JSON.generate(message))
313
+ @write_io.write("\n")
314
+ @write_io.flush
315
+ end
316
+ rescue Errno::EPIPE, IOError
317
+ signal_disconnect(nil)
318
+ end
319
+
320
+ def read_loop
321
+ buffer = +""
322
+ loop do
323
+ chunk = @read_io.readpartial(4096)
324
+ buffer << chunk
325
+ while (idx = buffer.index("\n"))
326
+ line = buffer.slice!(0, idx + 1).chomp
327
+ next if line.strip.empty?
328
+
329
+ handle_line(line)
330
+ end
331
+ end
332
+ rescue EOFError, IOError, Errno::EIO
333
+ nil
334
+ ensure
335
+ signal_disconnect(nil)
336
+ end
337
+
338
+ def handle_line(line)
339
+ message = JSON.parse(line)
340
+ rescue JSON::ParserError => e
341
+ signal_disconnect(e)
342
+ return
343
+ else
344
+ dispatch_message(message)
345
+ end
346
+
347
+ def dispatch_message(message)
348
+ if message["id"] && message["method"]
349
+ write_line({
350
+ jsonrpc: "2.0",
351
+ id: message["id"],
352
+ error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
353
+ })
354
+ return
355
+ end
356
+
357
+ if message.key?("id")
358
+ pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
359
+ return unless pending
360
+
361
+ if message["error"]
362
+ err_msg = message.dig("error", "message") || "RPC error"
363
+ pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
364
+ signal_disconnect(message["error"])
365
+ else
366
+ pending.push(message["result"] || {})
367
+ end
368
+ return
369
+ end
370
+
371
+ @notification_handler&.call(message) if message["method"]
372
+ end
373
+
374
+ def signal_disconnect(error)
375
+ return if @disconnect_signaled
376
+
377
+ @disconnect_signaled = true
378
+ @disconnect_handler&.call(error)
379
+ end
380
+
381
+ def process_alive?(pid)
382
+ Process.kill(0, pid)
383
+ true
384
+ rescue Errno::ESRCH, Errno::EPERM
385
+ false
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
@@ -1,6 +1,7 @@
1
1
  require_relative "adapters/base"
2
2
  require_relative "adapters/generic"
3
3
  require_relative "adapters/codex"
4
+ require_relative "adapters/codex_appserver"
4
5
  require_relative "adapters/claude"
5
6
 
6
7
  module Harnex
@@ -15,11 +16,23 @@ module Harnex
15
16
  !key.to_s.strip.empty?
16
17
  end
17
18
 
18
- def build(key, extra_args = [])
19
- adapter_class = registry[key.to_s]
19
+ # Phase 3 flipped the default — `codex` resolves to CodexAppServer.
20
+ # Legacy PTY adapter is reachable via `legacy_pty: true` (driven by
21
+ # `harnex run codex --legacy-pty`). Will be removed in 0.7.0.
22
+ def codex_appserver_enabled?
23
+ true
24
+ end
25
+
26
+ def build(key, extra_args = [], legacy_pty: false)
27
+ key_str = key.to_s
28
+ if key_str == "codex"
29
+ return legacy_pty ? Codex.new(extra_args) : CodexAppServer.new(extra_args)
30
+ end
31
+
32
+ adapter_class = registry[key_str]
20
33
  return adapter_class.new(extra_args) if adapter_class
21
34
 
22
- Generic.new(key.to_s, extra_args)
35
+ Generic.new(key_str, extra_args)
23
36
  end
24
37
 
25
38
  def registry
data/lib/harnex/cli.rb CHANGED
@@ -31,6 +31,8 @@ module Harnex
31
31
  Guide.new.run
32
32
  when "skills"
33
33
  Skills.new(@argv.drop(1)).run
34
+ when "doctor"
35
+ Doctor.new(@argv.drop(1)).run
34
36
  when "help"
35
37
  puts help(@argv[1])
36
38
  0
@@ -101,6 +103,7 @@ module Harnex
101
103
  recipes List and read workflow recipes
102
104
  guide Show the getting started guide
103
105
  skills Install bundled skills into a repo or globally
106
+ doctor Run preflight checks for adapter dependencies
104
107
  help Show command help
105
108
 
106
109
  New to harnex? Start with: harnex guide
@@ -0,0 +1,75 @@
1
+ require "json"
2
+
3
+ module Harnex
4
+ class Doctor
5
+ MIN_CODEX_VERSION = Gem::Version.new("0.128.0")
6
+
7
+ def self.usage
8
+ <<~TEXT
9
+ Usage: harnex doctor
10
+
11
+ Runs preflight checks for harnex's adapter dependencies.
12
+ Currently verifies that Codex CLI is installed and at version
13
+ >= #{MIN_CODEX_VERSION} (required for the JSON-RPC `app-server`
14
+ adapter).
15
+ TEXT
16
+ end
17
+
18
+ def initialize(argv = [])
19
+ @argv = argv.dup
20
+ end
21
+
22
+ def run
23
+ if @argv.include?("-h") || @argv.include?("--help")
24
+ puts self.class.usage
25
+ return 0
26
+ end
27
+
28
+ checks = [check_codex]
29
+ summary = {
30
+ ok: checks.all? { |c| c[:ok] },
31
+ checks: checks
32
+ }
33
+ puts JSON.generate(summary)
34
+ summary[:ok] ? 0 : 1
35
+ end
36
+
37
+ private
38
+
39
+ def check_codex
40
+ result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
41
+
42
+ version_output, status = capture("codex --version")
43
+ if status.nil?
44
+ return result.merge(ok: false, error: "codex CLI not found on PATH")
45
+ end
46
+ unless status.success?
47
+ return result.merge(ok: false, error: "codex --version failed: #{version_output.strip}")
48
+ end
49
+
50
+ version = parse_version(version_output)
51
+ if version.nil?
52
+ return result.merge(ok: false, found: version_output.strip, error: "could not parse codex version output")
53
+ end
54
+
55
+ if version < MIN_CODEX_VERSION
56
+ return result.merge(ok: false, found: version.to_s,
57
+ error: "codex #{version} < required #{MIN_CODEX_VERSION}; upgrade with `npm i -g @openai/codex` or your platform package manager")
58
+ end
59
+
60
+ result.merge(ok: true, found: version.to_s)
61
+ end
62
+
63
+ def capture(command)
64
+ output = `#{command} 2>&1`
65
+ [output, $?]
66
+ rescue StandardError => e
67
+ [e.message, nil]
68
+ end
69
+
70
+ def parse_version(text)
71
+ match = text.match(/(\d+\.\d+\.\d+)/)
72
+ match ? Gem::Version.new(match[1]) : nil
73
+ end
74
+ end
75
+ end