harnex 0.6.5 → 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
@@ -69,6 +70,10 @@ module Harnex
69
70
  :stdio_jsonrpc
70
71
  end
71
72
 
73
+ def provider
74
+ "openai"
75
+ end
76
+
72
77
  def base_command
73
78
  ["codex", "app-server"]
74
79
  end
@@ -135,10 +140,10 @@ module Harnex
135
140
  # subprocess. In tests, callers may pass pre-built IO objects.
136
141
  def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
137
142
  if read_io && write_io
138
- @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)
139
144
  else
140
145
  spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
141
- @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)
142
147
  end
143
148
 
144
149
  @client.on_notification { |msg| handle_notification(msg) }
@@ -189,6 +194,48 @@ module Harnex
189
194
  result
190
195
  end
191
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
+
192
239
  def close
193
240
  return unless @client
194
241
 
@@ -259,7 +306,12 @@ module Harnex
259
306
  end
260
307
 
261
308
  def perform_handshake
262
- @client.request("initialize", {
309
+ @client.request("initialize", handshake_initialize_params)
310
+ @client.notify("initialized", {})
311
+ end
312
+
313
+ def handshake_initialize_params
314
+ {
263
315
  clientInfo: {
264
316
  title: CLIENT_TITLE,
265
317
  name: CLIENT_NAME,
@@ -269,8 +321,7 @@ module Harnex
269
321
  experimentalApi: false,
270
322
  optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
271
323
  }
272
- })
273
- @client.notify("initialized", {})
324
+ }
274
325
  end
275
326
 
276
327
  def handle_notification(message)
@@ -314,231 +365,6 @@ module Harnex
314
365
  "Codex app-server is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
315
366
  end
316
367
 
317
- # Minimal JSON-RPC 2.0 client. One JSON object per line.
318
- # Responses keyed by id; everything else is a notification.
319
- class JsonRpcClient
320
- attr_reader :pid
321
-
322
- def initialize(read_io:, write_io:, pid: nil)
323
- @read_io = read_io
324
- @write_io = write_io
325
- @pid = pid
326
- @next_id = 1
327
- @pending = {}
328
- @id_mutex = Mutex.new
329
- @write_mutex = Mutex.new
330
- @notification_handler = nil
331
- @request_handler = nil
332
- @disconnect_handler = nil
333
- @disconnect_signaled = false
334
- @closed = false
335
- @reader_thread = nil
336
- end
337
-
338
- def on_notification(&block)
339
- @notification_handler = block
340
- end
341
-
342
- # Handler for server-initiated requests (id + method). The block
343
- # receives (method, params) and returns the response body for the
344
- # JSON-RPC `result` field, or nil to reject with -32601.
345
- def on_request(&block)
346
- @request_handler = block
347
- end
348
-
349
- def on_disconnect(&block)
350
- @disconnect_handler = block
351
- end
352
-
353
- def start
354
- @reader_thread = Thread.new { read_loop }
355
- end
356
-
357
- def request(method, params = {})
358
- raise "codex_appserver client is closed" if @closed
359
-
360
- queue = Queue.new
361
- id = @id_mutex.synchronize do
362
- assigned = @next_id
363
- @next_id += 1
364
- @pending[assigned] = queue
365
- assigned
366
- end
367
-
368
- write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
369
- result = queue.pop
370
- raise result if result.is_a?(Exception)
371
-
372
- result
373
- end
374
-
375
- def notify(method, params = {})
376
- return if @closed
377
-
378
- write_line({ jsonrpc: "2.0", method: method, params: params })
379
- end
380
-
381
- def close
382
- return if @closed
383
-
384
- @closed = true
385
-
386
- @id_mutex.synchronize do
387
- @pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
388
- @pending.clear
389
- end
390
-
391
- begin
392
- @write_io.close unless @write_io.closed?
393
- rescue IOError
394
- nil
395
- end
396
-
397
- if @pid && process_alive?(@pid)
398
- sleep 0.05
399
- begin
400
- Process.kill("TERM", @pid)
401
- rescue Errno::ESRCH
402
- nil
403
- end
404
- end
405
-
406
- @reader_thread&.join(2)
407
- end
408
-
409
- def terminate_process(term_grace_seconds:, kill_grace_seconds:)
410
- return false unless @pid
411
-
412
- begin
413
- Process.kill("TERM", @pid)
414
- rescue Errno::ESRCH
415
- return true
416
- end
417
-
418
- return true if wait_for_process_exit(@pid, term_grace_seconds)
419
-
420
- begin
421
- Process.kill("KILL", @pid)
422
- rescue Errno::ESRCH
423
- return true
424
- end
425
-
426
- wait_for_process_exit(@pid, kill_grace_seconds)
427
- end
428
-
429
- private
430
-
431
- def write_line(message)
432
- @write_mutex.synchronize do
433
- @write_io.write(JSON.generate(message))
434
- @write_io.write("\n")
435
- @write_io.flush
436
- end
437
- rescue Errno::EPIPE, IOError
438
- signal_disconnect(nil)
439
- end
440
-
441
- def read_loop
442
- buffer = +""
443
- loop do
444
- chunk = @read_io.readpartial(4096)
445
- buffer << chunk
446
- while (idx = buffer.index("\n"))
447
- line = buffer.slice!(0, idx + 1).chomp
448
- next if line.strip.empty?
449
-
450
- handle_line(line)
451
- end
452
- end
453
- rescue EOFError, IOError, Errno::EIO
454
- nil
455
- ensure
456
- signal_disconnect(nil)
457
- end
458
-
459
- def handle_line(line)
460
- message = JSON.parse(line)
461
- rescue JSON::ParserError => e
462
- signal_disconnect(e)
463
- return
464
- else
465
- dispatch_message(message)
466
- end
467
-
468
- def dispatch_message(message)
469
- if message["id"] && message["method"]
470
- handle_server_request(message)
471
- return
472
- end
473
-
474
- if message.key?("id")
475
- pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
476
- return unless pending
477
-
478
- if message["error"]
479
- err_msg = message.dig("error", "message") || "RPC error"
480
- pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
481
- signal_disconnect(message["error"])
482
- else
483
- pending.push(message["result"] || {})
484
- end
485
- return
486
- end
487
-
488
- @notification_handler&.call(message) if message["method"]
489
- end
490
-
491
- def handle_server_request(message)
492
- result =
493
- begin
494
- @request_handler&.call(message["method"], message["params"] || {})
495
- rescue StandardError
496
- nil
497
- end
498
-
499
- if result.nil?
500
- write_line({
501
- jsonrpc: "2.0",
502
- id: message["id"],
503
- error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
504
- })
505
- else
506
- write_line({
507
- jsonrpc: "2.0",
508
- id: message["id"],
509
- result: result
510
- })
511
- end
512
- end
513
-
514
- def signal_disconnect(error)
515
- return if @disconnect_signaled
516
-
517
- @disconnect_signaled = true
518
- @disconnect_handler&.call(error)
519
- end
520
-
521
- def process_alive?(pid)
522
- Process.kill(0, pid)
523
- true
524
- rescue Errno::ESRCH, Errno::EPERM
525
- false
526
- end
527
-
528
- def wait_for_process_exit(pid, timeout_seconds)
529
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds.to_f
530
- loop do
531
- return true unless process_alive?(pid)
532
-
533
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
534
- break if remaining <= 0
535
-
536
- sleep([0.05, remaining].min)
537
- end
538
-
539
- !process_alive?(pid)
540
- end
541
- end
542
368
  end
543
369
  end
544
370
  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