harnex 0.6.4 → 0.7.3

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.
@@ -1,5 +1,6 @@
1
1
  require "json"
2
2
  require "open3"
3
+ require_relative "../codex/app_server/client"
3
4
 
4
5
  module Harnex
5
6
  module Adapters
@@ -33,6 +34,8 @@ module Harnex
33
34
  ].freeze
34
35
 
35
36
  EVENTS = %w[task_complete turn_started item_completed disconnected].freeze
37
+ STOP_TERM_GRACE_SECONDS = 0.5
38
+ STOP_KILL_GRACE_SECONDS = 1.0
36
39
 
37
40
  # Server→client approval requests harnex auto-approves so dispatched
38
41
  # codex workers can run autonomously. Codex sends these via JSON-RPC
@@ -44,7 +47,7 @@ module Harnex
44
47
  APPROVAL_RESPONSES = {
45
48
  "applyPatchApproval" => { decision: "approved" },
46
49
  "execCommandApproval" => { decision: "approved" },
47
- "item/commandExecution/requestApproval" => { decision: "approved" },
50
+ "item/commandExecution/requestApproval" => { decision: "accept" },
48
51
  "item/fileChange/requestApproval" => { decision: "accept" }
49
52
  }.freeze
50
53
 
@@ -52,6 +55,7 @@ module Harnex
52
55
 
53
56
  def initialize(extra_args = [])
54
57
  super("codex", extra_args)
58
+ reject_unsupported_codex_flags!
55
59
  @initial_prompt = extract_initial_prompt(extra_args)
56
60
  @client = nil
57
61
  @thread_id = nil
@@ -66,6 +70,10 @@ module Harnex
66
70
  :stdio_jsonrpc
67
71
  end
68
72
 
73
+ def provider
74
+ "openai"
75
+ end
76
+
69
77
  def base_command
70
78
  ["codex", "app-server"]
71
79
  end
@@ -132,10 +140,10 @@ module Harnex
132
140
  # subprocess. In tests, callers may pass pre-built IO objects.
133
141
  def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
134
142
  if read_io && write_io
135
- @client = JsonRpcClient.new(read_io: read_io, write_io: write_io, pid: pid)
143
+ @client = Harnex::Codex::AppServer::Client.new(read_io: read_io, write_io: write_io, pid: pid)
136
144
  else
137
145
  spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
138
- @client = JsonRpcClient.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
146
+ @client = Harnex::Codex::AppServer::Client.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
139
147
  end
140
148
 
141
149
  @client.on_notification { |msg| handle_notification(msg) }
@@ -165,7 +173,7 @@ module Harnex
165
173
  params[:effort] = effort if effort
166
174
 
167
175
  result = @client.request("turn/start", params)
168
- @current_turn_id = result["turnId"] || result["turn_id"] || result["id"]
176
+ @current_turn_id = result.dig("turn", "id")
169
177
  @state = :busy
170
178
  @current_turn_id
171
179
  end
@@ -186,6 +194,48 @@ module Harnex
186
194
  result
187
195
  end
188
196
 
197
+ # Plan 30 Phase 2 — subprocess-restart primitive for deployment
198
+ # fallback. Stops the current JSON-RPC subprocess, spawns a new one
199
+ # against the supplied deployment_config, and resumes the same
200
+ # threadId so conversation state carries across. Thin orchestrator:
201
+ # counter snapshots, the `fallback_triggered` event, and any
202
+ # Session-level signaling land in plan 30 Phases 3–4 alongside the
203
+ # per-arm telemetry split. CLI flags land in Phase 5.
204
+ #
205
+ # deployment_config: { command: [...argv], env: {...}, cwd: nil }
206
+ def switch_deployment(deployment_config:,
207
+ term_grace_seconds: STOP_TERM_GRACE_SECONDS,
208
+ kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
209
+ raise "codex_appserver: client not started" unless @client
210
+ raise "codex_appserver: no thread to resume" if @thread_id.nil? || @thread_id.to_s.empty?
211
+
212
+ prior_thread_id = @thread_id
213
+ in_flight =
214
+ if @current_turn_id
215
+ { threadId: prior_thread_id, turnId: @current_turn_id }
216
+ end
217
+
218
+ @client.stop_for_fallback(
219
+ in_flight_turn: in_flight,
220
+ term_grace_seconds: term_grace_seconds,
221
+ kill_grace_seconds: kill_grace_seconds
222
+ )
223
+
224
+ @client = Harnex::Codex::AppServer::Client.spawn_with_fallback(
225
+ prior_thread_id: prior_thread_id,
226
+ deployment_config: deployment_config,
227
+ handshake_params: handshake_initialize_params,
228
+ notification_handler: ->(msg) { handle_notification(msg) },
229
+ request_handler: ->(method, params) { handle_server_request(method, params) },
230
+ disconnect_handler: ->(err) { handle_disconnect(err) }
231
+ )
232
+
233
+ @thread_id = prior_thread_id
234
+ @current_turn_id = nil
235
+ @state = :prompt
236
+ self
237
+ end
238
+
189
239
  def close
190
240
  return unless @client
191
241
 
@@ -194,6 +244,13 @@ module Harnex
194
244
  @state = :disconnected
195
245
  end
196
246
 
247
+ def terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
248
+ @client&.terminate_process(
249
+ term_grace_seconds: term_grace_seconds,
250
+ kill_grace_seconds: kill_grace_seconds
251
+ )
252
+ end
253
+
197
254
  def pid
198
255
  @client&.pid
199
256
  end
@@ -215,7 +272,7 @@ module Harnex
215
272
  def extract_thread_id(payload)
216
273
  return nil unless payload.is_a?(Hash)
217
274
 
218
- payload.dig("thread", "id") || payload["threadId"] || payload["thread_id"]
275
+ payload.dig("thread", "id")
219
276
  end
220
277
 
221
278
  def extract_initial_prompt(extra_args)
@@ -227,6 +284,21 @@ module Harnex
227
284
  nil
228
285
  end
229
286
 
287
+ # `codex app-server` does not implement `-m/--model`; passing it
288
+ # causes the subprocess to exit at startup, surfacing only as a
289
+ # null-message transport disconnect. Same flag still works on the
290
+ # legacy PTY adapter (`harnex run codex --legacy-pty`).
291
+ def reject_unsupported_codex_flags!
292
+ bad = @extra_args.find do |a|
293
+ s = a.to_s
294
+ s == "-m" || s == "--model" || s.start_with?("--model=")
295
+ end
296
+ return unless bad
297
+
298
+ raise ArgumentError,
299
+ "-m/--model is not supported by `codex app-server`. Use `-c model=\"<name>\"` instead."
300
+ end
301
+
230
302
  # Codex CLI flags only — strips the harnex-context entry that
231
303
  # `--context` smuggles through @extra_args.
232
304
  def cli_extra_args
@@ -234,7 +306,12 @@ module Harnex
234
306
  end
235
307
 
236
308
  def perform_handshake
237
- @client.request("initialize", {
309
+ @client.request("initialize", handshake_initialize_params)
310
+ @client.notify("initialized", {})
311
+ end
312
+
313
+ def handshake_initialize_params
314
+ {
238
315
  clientInfo: {
239
316
  title: CLIENT_TITLE,
240
317
  name: CLIENT_NAME,
@@ -244,8 +321,7 @@ module Harnex
244
321
  experimentalApi: false,
245
322
  optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
246
323
  }
247
- })
248
- @client.notify("initialized", {})
324
+ }
249
325
  end
250
326
 
251
327
  def handle_notification(message)
@@ -256,7 +332,7 @@ module Harnex
256
332
  when "thread/started"
257
333
  @thread_id ||= extract_thread_id(params)
258
334
  when "turn/started"
259
- @current_turn_id = params["turnId"] || params["turn_id"]
335
+ @current_turn_id = params.dig("turn", "id")
260
336
  @state = :busy
261
337
  when "turn/completed"
262
338
  @last_completed_at = Time.now
@@ -289,197 +365,6 @@ module Harnex
289
365
  "Codex app-server is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
290
366
  end
291
367
 
292
- # Minimal JSON-RPC 2.0 client. One JSON object per line.
293
- # Responses keyed by id; everything else is a notification.
294
- class JsonRpcClient
295
- attr_reader :pid
296
-
297
- def initialize(read_io:, write_io:, pid: nil)
298
- @read_io = read_io
299
- @write_io = write_io
300
- @pid = pid
301
- @next_id = 1
302
- @pending = {}
303
- @id_mutex = Mutex.new
304
- @write_mutex = Mutex.new
305
- @notification_handler = nil
306
- @request_handler = nil
307
- @disconnect_handler = nil
308
- @disconnect_signaled = false
309
- @closed = false
310
- @reader_thread = nil
311
- end
312
-
313
- def on_notification(&block)
314
- @notification_handler = block
315
- end
316
-
317
- # Handler for server-initiated requests (id + method). The block
318
- # receives (method, params) and returns the response body for the
319
- # JSON-RPC `result` field, or nil to reject with -32601.
320
- def on_request(&block)
321
- @request_handler = block
322
- end
323
-
324
- def on_disconnect(&block)
325
- @disconnect_handler = block
326
- end
327
-
328
- def start
329
- @reader_thread = Thread.new { read_loop }
330
- end
331
-
332
- def request(method, params = {})
333
- raise "codex_appserver client is closed" if @closed
334
-
335
- queue = Queue.new
336
- id = @id_mutex.synchronize do
337
- assigned = @next_id
338
- @next_id += 1
339
- @pending[assigned] = queue
340
- assigned
341
- end
342
-
343
- write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
344
- result = queue.pop
345
- raise result if result.is_a?(Exception)
346
-
347
- result
348
- end
349
-
350
- def notify(method, params = {})
351
- return if @closed
352
-
353
- write_line({ jsonrpc: "2.0", method: method, params: params })
354
- end
355
-
356
- def close
357
- return if @closed
358
-
359
- @closed = true
360
-
361
- @id_mutex.synchronize do
362
- @pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
363
- @pending.clear
364
- end
365
-
366
- begin
367
- @write_io.close unless @write_io.closed?
368
- rescue IOError
369
- nil
370
- end
371
-
372
- if @pid && process_alive?(@pid)
373
- sleep 0.05
374
- begin
375
- Process.kill("TERM", @pid)
376
- rescue Errno::ESRCH
377
- nil
378
- end
379
- end
380
-
381
- @reader_thread&.join(2)
382
- end
383
-
384
- private
385
-
386
- def write_line(message)
387
- @write_mutex.synchronize do
388
- @write_io.write(JSON.generate(message))
389
- @write_io.write("\n")
390
- @write_io.flush
391
- end
392
- rescue Errno::EPIPE, IOError
393
- signal_disconnect(nil)
394
- end
395
-
396
- def read_loop
397
- buffer = +""
398
- loop do
399
- chunk = @read_io.readpartial(4096)
400
- buffer << chunk
401
- while (idx = buffer.index("\n"))
402
- line = buffer.slice!(0, idx + 1).chomp
403
- next if line.strip.empty?
404
-
405
- handle_line(line)
406
- end
407
- end
408
- rescue EOFError, IOError, Errno::EIO
409
- nil
410
- ensure
411
- signal_disconnect(nil)
412
- end
413
-
414
- def handle_line(line)
415
- message = JSON.parse(line)
416
- rescue JSON::ParserError => e
417
- signal_disconnect(e)
418
- return
419
- else
420
- dispatch_message(message)
421
- end
422
-
423
- def dispatch_message(message)
424
- if message["id"] && message["method"]
425
- handle_server_request(message)
426
- return
427
- end
428
-
429
- if message.key?("id")
430
- pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
431
- return unless pending
432
-
433
- if message["error"]
434
- err_msg = message.dig("error", "message") || "RPC error"
435
- pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
436
- signal_disconnect(message["error"])
437
- else
438
- pending.push(message["result"] || {})
439
- end
440
- return
441
- end
442
-
443
- @notification_handler&.call(message) if message["method"]
444
- end
445
-
446
- def handle_server_request(message)
447
- result =
448
- begin
449
- @request_handler&.call(message["method"], message["params"] || {})
450
- rescue StandardError
451
- nil
452
- end
453
-
454
- if result.nil?
455
- write_line({
456
- jsonrpc: "2.0",
457
- id: message["id"],
458
- error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
459
- })
460
- else
461
- write_line({
462
- jsonrpc: "2.0",
463
- id: message["id"],
464
- result: result
465
- })
466
- end
467
- end
468
-
469
- def signal_disconnect(error)
470
- return if @disconnect_signaled
471
-
472
- @disconnect_signaled = true
473
- @disconnect_handler&.call(error)
474
- end
475
-
476
- def process_alive?(pid)
477
- Process.kill(0, pid)
478
- true
479
- rescue Errno::ESRCH, Errno::EPERM
480
- false
481
- end
482
- end
483
368
  end
484
369
  end
485
370
  end
@@ -9,6 +9,17 @@ module Harnex
9
9
  def base_command
10
10
  [@cli_name]
11
11
  end
12
+
13
+ def input_state(screen_text)
14
+ if recent_lines(screen_text).any? { |line| prompt_line?(line) }
15
+ {
16
+ state: "prompt",
17
+ input_ready: true
18
+ }
19
+ else
20
+ super
21
+ end
22
+ end
12
23
  end
13
24
  end
14
25
  end
@@ -0,0 +1,132 @@
1
+ module Harnex
2
+ module Adapters
3
+ class Opencode < Base
4
+ SUBMIT_DELAY_MS = 75
5
+ SUBMIT_DELAY_PER_KB_MS = 50
6
+ EXIT_SIGNAL_DELAY_MS = 100
7
+
8
+ def initialize(extra_args = [])
9
+ super("opencode", extra_args)
10
+ @screen_seen = false
11
+ end
12
+
13
+ def provider
14
+ "opencode"
15
+ end
16
+
17
+ def base_command
18
+ ["opencode"]
19
+ end
20
+
21
+ def infer_repo_path(argv)
22
+ index = 0
23
+ while index < argv.length
24
+ arg = argv[index].to_s
25
+ case arg
26
+ when "--dir"
27
+ next_value = argv[index + 1]
28
+ return next_value if next_value && !next_value.to_s.strip.empty?
29
+ break
30
+ when /\A--dir=(.+)\z/
31
+ return Regexp.last_match(1)
32
+ end
33
+ index += 1
34
+ end
35
+
36
+ positional = argv.find { |value| value && !value.start_with?("-") }
37
+ return positional if positional
38
+
39
+ Dir.pwd
40
+ end
41
+
42
+ def input_state(screen_text)
43
+ @screen_seen ||= !screen_text.to_s.empty?
44
+ lines = recent_lines(screen_text, limit: 80)
45
+ return prompt_state if @screen_seen && lines.empty?
46
+ return prompt_state if lines.any? { |line| prompt_line?(line) }
47
+
48
+ compact = lines.join(" ").gsub(/\s+/, " ")
49
+
50
+ # OpenCode's TUI keeps rendering on an alternate screen and does
51
+ # not expose a stable prompt token in plain text snapshots. Treat
52
+ # observed screen content as send-ready so inbox messages don't
53
+ # stall indefinitely in :unknown.
54
+ return prompt_state if compact.match?(/\bOpenCode\b/i)
55
+ return prompt_state if compact.match?(/\bSession\b.*\bContinue\b.*\bopencode\s+-s\b/i)
56
+ return prompt_state if compact.match?(/[■⬝╹]/)
57
+
58
+ return prompt_state if @screen_seen
59
+
60
+ super
61
+ end
62
+
63
+ def parse_session_summary(transcript_tail)
64
+ summary = {
65
+ input_tokens: nil,
66
+ output_tokens: nil,
67
+ reasoning_tokens: nil,
68
+ cached_tokens: nil,
69
+ total_tokens: nil,
70
+ agent_session_id: nil
71
+ }
72
+
73
+ text = normalized_screen_text(transcript_tail)
74
+ if (match = text.match(/\bContinue\s+opencode\s+-s\s+([A-Za-z0-9._:-]+)/))
75
+ summary[:agent_session_id] = match[1]
76
+ end
77
+
78
+ summary
79
+ end
80
+
81
+ def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
82
+ state = input_state(screen_text)
83
+ if !force && blocked_state?(state, enter_only: enter_only)
84
+ raise ArgumentError, blocked_message(state, enter_only: enter_only)
85
+ end
86
+
87
+ steps = []
88
+ unless enter_only
89
+ body = text.to_s
90
+ steps << { text: body, newline: false } unless body.empty?
91
+ end
92
+
93
+ if submit || enter_only
94
+ step = { text: submit_bytes, newline: false }
95
+ step[:delay_ms] = submit_delay_ms(text) if steps.any?
96
+ steps << step
97
+ end
98
+
99
+ {
100
+ steps: steps,
101
+ input_state: state,
102
+ force: force
103
+ }
104
+ end
105
+
106
+ def inject_exit(writer)
107
+ # Ctrl+C is OpenCode's native terminal stop path. A second signal
108
+ # shortly after the first handles "interrupt-first, quit-second"
109
+ # cases when work is in-flight.
110
+ writer.write("\u0003")
111
+ writer.flush
112
+ sleep(EXIT_SIGNAL_DELAY_MS / 1000.0)
113
+ writer.write("\u0003")
114
+ writer.flush
115
+ end
116
+
117
+ private
118
+
119
+ def prompt_state
120
+ {
121
+ state: "prompt",
122
+ input_ready: true
123
+ }
124
+ end
125
+
126
+ def submit_delay_ms(text)
127
+ extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
128
+ SUBMIT_DELAY_MS + extra
129
+ end
130
+ end
131
+ end
132
+ end
@@ -3,6 +3,7 @@ require_relative "adapters/generic"
3
3
  require_relative "adapters/codex"
4
4
  require_relative "adapters/codex_appserver"
5
5
  require_relative "adapters/claude"
6
+ require_relative "adapters/opencode"
6
7
 
7
8
  module Harnex
8
9
  module Adapters
@@ -39,7 +40,8 @@ module Harnex
39
40
  def registry
40
41
  @registry ||= {
41
42
  "claude" => Claude,
42
- "codex" => Codex
43
+ "codex" => Codex,
44
+ "opencode" => Opencode
43
45
  }
44
46
  end
45
47
  end
data/lib/harnex/cli.rb CHANGED
@@ -23,6 +23,8 @@ module Harnex
23
23
  Logs.new(@argv.drop(1)).run
24
24
  when "events"
25
25
  Events.new(@argv.drop(1)).run
26
+ when "history"
27
+ History.new(@argv.drop(1)).run
26
28
  when "pane"
27
29
  Pane.new(@argv.drop(1)).run
28
30
  when "recipes"
@@ -65,6 +67,8 @@ module Harnex
65
67
  Logs.usage
66
68
  when "events"
67
69
  Events.usage
70
+ when "history"
71
+ History.usage
68
72
  when "pane"
69
73
  Pane.usage
70
74
  when "recipes"
@@ -90,6 +94,7 @@ module Harnex
90
94
  harnex status [options]
91
95
  harnex logs --id ID [options]
92
96
  harnex events --id ID [options]
97
+ harnex history [options]
93
98
  harnex pane --id ID [options]
94
99
  harnex agents-guide [topic]
95
100
  harnex doctor
@@ -103,12 +108,13 @@ module Harnex
103
108
  status List live sessions
104
109
  logs Read session output transcripts
105
110
  events Stream per-session JSONL runtime events
111
+ history List completed dispatches from .harnex/dispatch.jsonl
106
112
  pane Capture the current tmux pane for a live session
107
113
  recipes List and read workflow recipes
108
114
  guide Show the getting started guide
109
115
  agents-guide
110
116
  Show agent dispatch, chain, buddy, monitoring, and naming guides
111
- doctor Run preflight checks for adapter dependencies
117
+ doctor Run preflight checks and optional read-only session sweep
112
118
  help Show command help
113
119
 
114
120
  New to harnex? Start with: harnex guide
@@ -125,6 +131,7 @@ module Harnex
125
131
  harnex status
126
132
  harnex logs --id main --follow
127
133
  harnex events --id main --snapshot
134
+ harnex history --limit 20
128
135
  harnex pane --id main --lines 40
129
136
  harnex agents-guide dispatch
130
137
  harnex doctor