harnex 0.6.5 → 0.7.4

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.
@@ -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
@@ -0,0 +1,512 @@
1
+ require "json"
2
+ require "open3"
3
+
4
+ module Harnex
5
+ module Adapters
6
+ # Pi RPC adapter — JSONL command/event protocol over stdio.
7
+ #
8
+ # Protocol docs: `pi --mode rpc` (strict LF-delimited JSON lines).
9
+ class Pi < Base
10
+ STOP_TERM_GRACE_SECONDS = 0.5
11
+ STOP_KILL_GRACE_SECONDS = 1.0
12
+ DIALOG_UI_METHODS = %w[select confirm input editor].freeze
13
+
14
+ attr_reader :initial_prompt, :last_completed_at
15
+
16
+ def initialize(extra_args = [])
17
+ super("pi", extra_args)
18
+ @initial_prompt = extract_initial_prompt(extra_args)
19
+ @notification_handler = nil
20
+ @disconnect_handler = nil
21
+ @read_io = nil
22
+ @write_io = nil
23
+ @pid = nil
24
+ @reader_thread = nil
25
+ @closed = false
26
+ @disconnect_signaled = false
27
+ @state = :disconnected
28
+ @next_id = 1
29
+ @pending = {}
30
+ @id_mutex = Mutex.new
31
+ @write_mutex = Mutex.new
32
+ @summary_mutex = Mutex.new
33
+ @session_summary = {}
34
+ @model = nil
35
+ @provider = nil
36
+ @session_stats_requested = false
37
+ @last_completed_at = nil
38
+ end
39
+
40
+ def transport
41
+ :stdio_jsonl_rpc
42
+ end
43
+
44
+ def provider
45
+ @provider
46
+ end
47
+
48
+ def current_model
49
+ @model
50
+ end
51
+
52
+ def base_command
53
+ ["pi", "--mode", "rpc"]
54
+ end
55
+
56
+ def build_command
57
+ base_command + cli_extra_args
58
+ end
59
+
60
+ def describe
61
+ {
62
+ transport: transport,
63
+ protocol: "jsonl",
64
+ events: %w[
65
+ agent_start agent_end turn_start turn_end message_start message_update message_end
66
+ tool_execution_start tool_execution_update tool_execution_end queue_update
67
+ compaction_start compaction_end auto_retry_start auto_retry_end extension_error
68
+ extension_ui_request
69
+ ]
70
+ }
71
+ end
72
+
73
+ def state
74
+ @state
75
+ end
76
+
77
+ def input_state(_screen_text = nil)
78
+ {
79
+ state: @state.to_s,
80
+ input_ready: @state == :prompt
81
+ }
82
+ end
83
+
84
+ def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
85
+ state = input_state(nil)
86
+ if !force && submit && !enter_only && state[:input_ready] != true
87
+ raise ArgumentError, blocked_message(state, enter_only: enter_only)
88
+ end
89
+
90
+ raise ArgumentError, "Pi RPC cannot stage input without submitting it" unless submit || enter_only
91
+ raise ArgumentError, "Pi RPC does not support submit-only input" if enter_only
92
+
93
+ {
94
+ dispatch: { prompt: text.to_s },
95
+ input_state: state,
96
+ force: force
97
+ }
98
+ end
99
+
100
+ def inject_exit(_writer, **_kwargs)
101
+ nil
102
+ end
103
+
104
+ def on_notification(&block)
105
+ @notification_handler = block
106
+ end
107
+
108
+ def on_disconnect(&block)
109
+ @disconnect_handler = block
110
+ end
111
+
112
+ def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
113
+ if read_io && write_io
114
+ @read_io = read_io
115
+ @write_io = write_io
116
+ @pid = pid
117
+ else
118
+ @pid, @write_io, @read_io = spawn_subprocess(env, cwd)
119
+ end
120
+
121
+ @closed = false
122
+ @disconnect_signaled = false
123
+ @state = :prompt
124
+ @reader_thread = Thread.new { read_loop }
125
+ request_state_async
126
+ self
127
+ end
128
+
129
+ def dispatch(prompt:, model: nil, effort: nil)
130
+ ensure_open!
131
+
132
+ payload = { "type" => "prompt", "message" => prompt.to_s }
133
+ payload["model"] = model if model
134
+ payload["thinkingLevel"] = effort if effort
135
+
136
+ request(payload)
137
+ @state = :busy
138
+ nil
139
+ end
140
+
141
+ def interrupt(turn_id: nil)
142
+ ensure_open!
143
+ request("type" => "abort")
144
+ rescue StandardError
145
+ nil
146
+ end
147
+
148
+ def request_session_stats_async
149
+ return if @closed
150
+ return if @session_stats_requested
151
+
152
+ @session_stats_requested = true
153
+ write_line("type" => "get_session_stats")
154
+ rescue StandardError
155
+ nil
156
+ end
157
+
158
+ def respond_extension_ui_cancel(request_id:, method:)
159
+ return false unless DIALOG_UI_METHODS.include?(method.to_s)
160
+
161
+ write_line(
162
+ "type" => "extension_ui_response",
163
+ "id" => request_id,
164
+ "cancelled" => true
165
+ )
166
+ true
167
+ rescue StandardError
168
+ false
169
+ end
170
+
171
+ def collect_session_summary
172
+ attempt_live_summary_refresh if connected?
173
+
174
+ @summary_mutex.synchronize do
175
+ {
176
+ input_tokens: @session_summary[:input_tokens],
177
+ output_tokens: @session_summary[:output_tokens],
178
+ reasoning_tokens: nil,
179
+ cached_tokens: @session_summary[:cached_tokens],
180
+ total_tokens: @session_summary[:total_tokens],
181
+ agent_session_id: @session_summary[:agent_session_id],
182
+ tool_calls: @session_summary[:tool_calls],
183
+ cost_usd: @session_summary[:cost_usd],
184
+ model: @session_summary[:model],
185
+ agent_provider: @session_summary[:agent_provider]
186
+ }
187
+ end
188
+ end
189
+
190
+ def close
191
+ return if @closed
192
+
193
+ @closed = true
194
+ fail_pending_requests(StandardError.new("pi rpc client closed"))
195
+
196
+ begin
197
+ @write_io.close unless @write_io&.closed?
198
+ rescue IOError
199
+ nil
200
+ end
201
+
202
+ @reader_thread&.join(2)
203
+ ensure
204
+ terminate_subprocess(
205
+ term_grace_seconds: STOP_TERM_GRACE_SECONDS,
206
+ kill_grace_seconds: STOP_KILL_GRACE_SECONDS
207
+ )
208
+ end
209
+
210
+ def terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
211
+ return false unless @pid
212
+
213
+ begin
214
+ Process.kill("TERM", @pid)
215
+ rescue Errno::ESRCH
216
+ return true
217
+ end
218
+
219
+ return true if wait_for_process_exit(@pid, term_grace_seconds)
220
+
221
+ begin
222
+ Process.kill("KILL", @pid)
223
+ rescue Errno::ESRCH
224
+ return true
225
+ end
226
+
227
+ wait_for_process_exit(@pid, kill_grace_seconds)
228
+ end
229
+
230
+ def pid
231
+ @pid
232
+ end
233
+
234
+ private
235
+
236
+ def request(payload)
237
+ raise "pi rpc client is closed" if @closed
238
+
239
+ queue = Queue.new
240
+ id = @id_mutex.synchronize do
241
+ assigned = @next_id
242
+ @next_id += 1
243
+ @pending[assigned] = queue
244
+ assigned
245
+ end
246
+
247
+ write_line(payload.merge("id" => id))
248
+ response = queue.pop
249
+ raise response if response.is_a?(Exception)
250
+
251
+ unless response["success"]
252
+ raise "pi rpc #{response["command"] || payload["type"]} failed: #{response["error"] || "unknown error"}"
253
+ end
254
+
255
+ handle_response_data(response)
256
+ response["data"] || {}
257
+ end
258
+
259
+ def request_state_async
260
+ write_line("type" => "get_state")
261
+ rescue StandardError
262
+ nil
263
+ end
264
+
265
+ def attempt_live_summary_refresh
266
+ response = request("type" => "get_session_stats")
267
+ absorb_session_stats(response)
268
+ rescue StandardError
269
+ nil
270
+ end
271
+
272
+ def read_loop
273
+ buffer = +""
274
+ loop do
275
+ chunk = @read_io.readpartial(4096)
276
+ buffer << chunk
277
+
278
+ while (idx = buffer.index("\n"))
279
+ line = buffer.slice!(0, idx + 1)
280
+ line = line.chomp("\n")
281
+ line = line.chomp("\r")
282
+ next if line.strip.empty?
283
+
284
+ handle_line(line)
285
+ end
286
+ end
287
+ rescue EOFError, IOError, Errno::EIO
288
+ nil
289
+ ensure
290
+ signal_disconnect(nil)
291
+ end
292
+
293
+ def handle_line(line)
294
+ message = JSON.parse(line)
295
+ handle_message(message)
296
+ rescue JSON::ParserError => e
297
+ signal_disconnect(e)
298
+ end
299
+
300
+ def handle_message(message)
301
+ if message["type"] == "response"
302
+ handle_response(message)
303
+ else
304
+ handle_event(message)
305
+ end
306
+ end
307
+
308
+ def handle_response(message)
309
+ pending = nil
310
+ if message.key?("id") && !message["id"].nil?
311
+ pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
312
+ end
313
+
314
+ if pending
315
+ pending.push(message)
316
+ return
317
+ end
318
+
319
+ handle_response_data(message)
320
+ end
321
+
322
+ def handle_response_data(message)
323
+ return unless message["type"] == "response" && message["success"]
324
+
325
+ case message["command"]
326
+ when "get_state"
327
+ absorb_state_data(message["data"])
328
+ when "get_session_stats"
329
+ absorb_session_stats(message["data"])
330
+ end
331
+ end
332
+
333
+ def handle_event(message)
334
+ case message["type"]
335
+ when "agent_start", "turn_start"
336
+ @state = :busy
337
+ when "agent_end"
338
+ @state = :prompt
339
+ @last_completed_at = Time.now
340
+ request_session_stats_async
341
+ when "message_end"
342
+ absorb_model_from_message(message["message"])
343
+ end
344
+
345
+ @notification_handler&.call(message)
346
+ rescue StandardError
347
+ nil
348
+ end
349
+
350
+ def absorb_state_data(data)
351
+ return unless data.is_a?(Hash)
352
+
353
+ @state = data["isStreaming"] ? :busy : :prompt
354
+ @state = :busy if data["isCompacting"]
355
+ @summary_mutex.synchronize do
356
+ @session_summary[:agent_session_id] = data["sessionId"] if data["sessionId"]
357
+ end
358
+ absorb_model(data["model"])
359
+ end
360
+
361
+ def absorb_session_stats(data)
362
+ return unless data.is_a?(Hash)
363
+
364
+ tokens = data["tokens"] || {}
365
+ @summary_mutex.synchronize do
366
+ @session_summary[:input_tokens] = numeric_or_nil(tokens["input"])
367
+ @session_summary[:output_tokens] = numeric_or_nil(tokens["output"])
368
+ @session_summary[:cached_tokens] = numeric_or_nil(tokens["cacheRead"])
369
+ @session_summary[:total_tokens] = numeric_or_nil(tokens["total"])
370
+ @session_summary[:tool_calls] = numeric_or_nil(data["toolCalls"])
371
+ @session_summary[:cost_usd] = float_or_nil(data["cost"])
372
+ @session_summary[:agent_session_id] = data["sessionId"] if data["sessionId"]
373
+ @session_summary[:model] = @model if @model
374
+ @session_summary[:agent_provider] = @provider if @provider
375
+ end
376
+ end
377
+
378
+ def absorb_model_from_message(message)
379
+ return unless message.is_a?(Hash)
380
+
381
+ absorb_model({
382
+ "provider" => message["provider"],
383
+ "id" => message["model"]
384
+ })
385
+ end
386
+
387
+ def absorb_model(model)
388
+ case model
389
+ when Hash
390
+ model_id = model["id"]
391
+ provider = model["provider"]
392
+ @model = model_id if model_id.is_a?(String) && !model_id.empty?
393
+ @provider = provider if provider.is_a?(String) && !provider.empty?
394
+ if @provider.nil? && @model&.include?("/")
395
+ @provider = @model.split("/", 2).first
396
+ end
397
+ when String
398
+ return if model.empty?
399
+
400
+ @model = model
401
+ @provider = model.split("/", 2).first if model.include?("/")
402
+ end
403
+ end
404
+
405
+ def numeric_or_nil(value)
406
+ return nil if value.nil?
407
+
408
+ Integer(value)
409
+ rescue ArgumentError, TypeError
410
+ nil
411
+ end
412
+
413
+ def float_or_nil(value)
414
+ return nil if value.nil?
415
+
416
+ Float(value)
417
+ rescue ArgumentError, TypeError
418
+ nil
419
+ end
420
+
421
+ def write_line(payload)
422
+ raise "pi rpc client is closed" if @closed
423
+
424
+ @write_mutex.synchronize do
425
+ @write_io.write(JSON.generate(payload))
426
+ @write_io.write("\n")
427
+ @write_io.flush
428
+ end
429
+ rescue Errno::EPIPE, IOError => e
430
+ signal_disconnect(e)
431
+ raise
432
+ end
433
+
434
+ def ensure_open!
435
+ raise "pi rpc client not started" unless @read_io && @write_io
436
+ raise "pi rpc disconnected" if @state == :disconnected
437
+ end
438
+
439
+ def connected?
440
+ @read_io && @write_io && !@closed && @state != :disconnected
441
+ end
442
+
443
+ def fail_pending_requests(error)
444
+ @id_mutex.synchronize do
445
+ @pending.each_value { |queue| queue.push(error) }
446
+ @pending.clear
447
+ end
448
+ end
449
+
450
+ def signal_disconnect(error)
451
+ return if @disconnect_signaled
452
+
453
+ @disconnect_signaled = true
454
+ @state = :disconnected
455
+ fail_pending_requests(
456
+ error.is_a?(Exception) ? error : StandardError.new("pi rpc disconnected")
457
+ )
458
+ @disconnect_handler&.call(error)
459
+ end
460
+
461
+ def process_alive?(pid)
462
+ Process.kill(0, pid)
463
+ true
464
+ rescue Errno::ESRCH
465
+ false
466
+ rescue Errno::EPERM
467
+ true
468
+ end
469
+
470
+ def wait_for_process_exit(pid, timeout_seconds)
471
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds.to_f
472
+ loop do
473
+ return true unless process_alive?(pid)
474
+
475
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
476
+ break if remaining <= 0
477
+
478
+ sleep([0.05, remaining].min)
479
+ end
480
+
481
+ !process_alive?(pid)
482
+ end
483
+
484
+ def extract_initial_prompt(extra_args)
485
+ return nil unless extra_args.is_a?(Array)
486
+
487
+ prefixed = extra_args.find { |a| a.is_a?(String) && a.start_with?("[harnex session id=") }
488
+ return prefixed if prefixed && !prefixed.empty?
489
+
490
+ nil
491
+ end
492
+
493
+ def cli_extra_args
494
+ @extra_args.reject { |a| a.is_a?(String) && a.start_with?("[harnex session id=") }
495
+ end
496
+
497
+ def spawn_subprocess(env, cwd)
498
+ spawn_env = env || {}
499
+ opts = {}
500
+ opts[:chdir] = cwd if cwd
501
+ stdin_io, stdout_io, _stderr_io, wait_thr = Open3.popen3(spawn_env, *build_command, **opts)
502
+ [wait_thr.pid, stdin_io, stdout_io]
503
+ end
504
+
505
+ def blocked_message(state, enter_only:)
506
+ return super if enter_only
507
+
508
+ "Pi RPC is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
509
+ end
510
+ end
511
+ end
512
+ end
@@ -3,6 +3,8 @@ 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"
7
+ require_relative "adapters/pi"
6
8
 
7
9
  module Harnex
8
10
  module Adapters
@@ -39,7 +41,9 @@ module Harnex
39
41
  def registry
40
42
  @registry ||= {
41
43
  "claude" => Claude,
42
- "codex" => Codex
44
+ "codex" => Codex,
45
+ "opencode" => Opencode,
46
+ "pi" => Pi
43
47
  }
44
48
  end
45
49
  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
@@ -119,12 +125,13 @@ module Harnex
119
125
  Any other CLI name is launched with generic wrapping.
120
126
 
121
127
  Examples:
122
- harnex run codex
128
+ harnex run pi
123
129
  harnex run aider --id blue-cat
124
130
  harnex run codex -- --cd /path/to/repo
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