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.
@@ -6,16 +6,25 @@ module Harnex
6
6
  class Session
7
7
  OUTPUT_BUFFER_LIMIT = 64 * 1024
8
8
  TRANSCRIPT_TAIL_BYTES = 16 * 1024
9
+ AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT = 5.0
9
10
  USAGE_FIELDS = %i[
10
- input_tokens output_tokens reasoning_tokens cached_tokens total_tokens agent_session_id
11
+ input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
12
+ agent_session_id cost_usd tool_calls model agent_provider
11
13
  ].freeze
14
+ SESSION_SUMMARY_SIGNAL_FIELDS = %i[
15
+ input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
16
+ agent_session_id cost_usd
17
+ ].freeze
18
+ BUDGET_META_FIELDS = %w[read_budget_lines output_ceiling_lines].freeze
12
19
  class EventCounters
13
20
  def initialize
14
21
  @counts = {
15
22
  stalls: 0,
16
23
  force_resumes: 0,
17
24
  disconnections: 0,
18
- compactions: 0
25
+ compactions: 0,
26
+ tool_calls: 0,
27
+ commands_executed: 0
19
28
  }
20
29
  end
21
30
 
@@ -32,17 +41,31 @@ module Harnex
32
41
  end
33
42
  end
34
43
 
44
+ def record_item(item)
45
+ return unless item.is_a?(Hash)
46
+
47
+ case item["type"]
48
+ when "mcpToolCall", "dynamicToolCall"
49
+ @counts[:tool_calls] += 1
50
+ when "commandExecution"
51
+ @counts[:commands_executed] += 1
52
+ end
53
+ end
54
+
35
55
  def snapshot
36
56
  @counts.dup
37
57
  end
38
58
  end
39
59
 
40
- attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path
60
+ attr_reader :repo_root, :launch_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
61
+ :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path,
62
+ :started_at, :ended_at, :exit_code, :term_signal
41
63
 
42
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false)
64
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil)
43
65
  @adapter = adapter
44
66
  @command = command
45
67
  @repo_root = repo_root
68
+ @launch_cwd = File.expand_path(launch_cwd.to_s.empty? ? repo_root : launch_cwd)
46
69
  @host = host
47
70
  @id = Harnex.normalize_id(id)
48
71
  @watch = watch
@@ -80,9 +103,11 @@ module Harnex
80
103
  @session_finalized = false
81
104
  @turn_started_seen = false
82
105
  @last_completed_at = nil
106
+ @pi_streamed_text_by_message = {}
83
107
  @auto_stop = !!auto_stop
84
108
  @auto_stop_fired = false
85
109
  @auto_stop_seen_busy = false
110
+ @auto_stop_threads = []
86
111
  @stop_requested = false
87
112
  @writer = nil
88
113
  @pid = nil
@@ -91,6 +116,9 @@ module Harnex
91
116
  @output_buffer.force_encoding(Encoding::BINARY)
92
117
  @state_machine = SessionState.new(adapter)
93
118
  @inbox = Inbox.new(self, @state_machine, ttl: inbox_ttl)
119
+ @rate_limits = nil
120
+ @parent_harnex_id = ENV["HARNEX_ID"].to_s.strip
121
+ @parent_harnex_id = nil if @parent_harnex_id.empty?
94
122
  end
95
123
 
96
124
  def self.validate_binary!(command)
@@ -116,7 +144,7 @@ module Harnex
116
144
  prepare_output_log
117
145
  prepare_events_log
118
146
 
119
- return run_jsonrpc if adapter.transport == :stdio_jsonrpc
147
+ return run_structured if structured_transport?
120
148
 
121
149
  run_pty
122
150
  end
@@ -145,6 +173,8 @@ module Harnex
145
173
  @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
146
174
  @ended_at = Time.now
147
175
 
176
+ normalize_auto_stop_exit_code!
177
+ drain_auto_stop_threads
148
178
  output_thread.join(1)
149
179
  finalize_session!
150
180
  input_thread&.kill
@@ -194,12 +224,24 @@ module Harnex
194
224
  payload[:agent_state] = @state_machine.to_s
195
225
  payload[:inbox] = @inbox.stats
196
226
  payload[:last_completed_at] = @last_completed_at&.iso8601
197
- payload[:model] = meta_hash["model"]
227
+ payload[:model] = summary_model
198
228
  payload[:effort] = meta_hash["effort"]
199
229
  payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
200
230
  payload
201
231
  end
202
232
 
233
+ def task_complete?
234
+ !!@last_completed_at
235
+ end
236
+
237
+ def git_start
238
+ @git_start || {}
239
+ end
240
+
241
+ def git_end
242
+ @git_end || {}
243
+ end
244
+
203
245
  def auth_ok?(header)
204
246
  header == "Bearer #{token}"
205
247
  end
@@ -211,21 +253,13 @@ module Harnex
211
253
  end
212
254
 
213
255
  def inject_stop(turn_id: nil)
214
- unless adapter.transport == :stdio_jsonrpc
256
+ unless structured_transport?
215
257
  raise "session is not running" unless pid && Harnex.alive_pid?(pid)
216
258
  end
217
259
 
218
260
  return { ok: true, signal: "already_requested" } if stop_requested!
219
261
 
220
- if adapter.transport == :stdio_jsonrpc
221
- @inject_mutex.synchronize do
222
- begin
223
- adapter.interrupt(turn_id: turn_id)
224
- rescue StandardError
225
- nil
226
- end
227
- @state_machine.force_busy!
228
- end
262
+ if structured_transport?
229
263
  if adapter.respond_to?(:terminate_subprocess)
230
264
  Thread.new do
231
265
  begin
@@ -235,6 +269,14 @@ module Harnex
235
269
  end
236
270
  end
237
271
  end
272
+ @inject_mutex.synchronize do
273
+ begin
274
+ adapter.interrupt(turn_id: turn_id)
275
+ rescue StandardError
276
+ nil
277
+ end
278
+ @state_machine.force_busy!
279
+ end
238
280
  return { ok: true, signal: "interrupt_sent" }
239
281
  end
240
282
 
@@ -247,8 +289,8 @@ module Harnex
247
289
  end
248
290
 
249
291
  def inject_via_adapter(text:, submit:, enter_only:, force: false)
250
- if adapter.transport == :stdio_jsonrpc
251
- return inject_via_jsonrpc(text: text, submit: submit, enter_only: enter_only, force: force)
292
+ if structured_transport?
293
+ return inject_via_structured(text: text, submit: submit, enter_only: enter_only, force: force)
252
294
  end
253
295
 
254
296
  snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
@@ -275,7 +317,7 @@ module Harnex
275
317
  .tap { emit_send_event(text, force: payload[:force]) }
276
318
  end
277
319
 
278
- def inject_via_jsonrpc(text:, submit:, enter_only:, force: false)
320
+ def inject_via_structured(text:, submit:, enter_only:, force: false)
279
321
  payload = adapter.build_send_payload(
280
322
  text: text,
281
323
  submit: submit,
@@ -324,9 +366,13 @@ module Harnex
324
366
 
325
367
  private
326
368
 
327
- def run_jsonrpc
328
- adapter.on_notification { |msg| handle_rpc_notification(msg) }
329
- adapter.on_disconnect { |err| handle_rpc_disconnect(err) }
369
+ def structured_transport?
370
+ %i[stdio_jsonrpc stdio_jsonl_rpc].include?(adapter.transport)
371
+ end
372
+
373
+ def run_structured
374
+ adapter.on_notification { |msg| handle_structured_notification(msg) }
375
+ adapter.on_disconnect { |err| handle_structured_disconnect(err) }
330
376
 
331
377
  adapter.start_rpc(env: child_env, cwd: repo_root)
332
378
  @pid = adapter.pid
@@ -359,6 +405,8 @@ module Harnex
359
405
  end
360
406
  @ended_at = Time.now
361
407
 
408
+ normalize_auto_stop_exit_code!
409
+ drain_auto_stop_threads
362
410
  finalize_session!
363
411
  watch_thread&.kill
364
412
  @exit_code
@@ -377,6 +425,8 @@ module Harnex
377
425
  @events_log&.close unless @events_log&.closed?
378
426
  end
379
427
 
428
+ alias run_jsonrpc run_structured
429
+
380
430
  def signal_rpc_done!
381
431
  @rpc_done = true
382
432
  if defined?(@rpc_done_lock) && @rpc_done_lock
@@ -384,6 +434,15 @@ module Harnex
384
434
  end
385
435
  end
386
436
 
437
+ def handle_structured_notification(message)
438
+ case adapter.transport
439
+ when :stdio_jsonrpc
440
+ handle_rpc_notification(message)
441
+ when :stdio_jsonl_rpc
442
+ handle_jsonl_notification(message)
443
+ end
444
+ end
445
+
387
446
  def handle_rpc_notification(message)
388
447
  method = message["method"]
389
448
  params = message["params"] || {}
@@ -406,6 +465,7 @@ module Harnex
406
465
  schedule_auto_stop("task_complete", turn_id: payload[:turnId])
407
466
  when "item/completed"
408
467
  emit_event("item_completed", item: params["item"])
468
+ @event_counters.record_item(params["item"])
409
469
  text = render_item_text(params["item"])
410
470
  record_synthesized(text) if text
411
471
  when "thread/compacted"
@@ -431,6 +491,86 @@ module Harnex
431
491
  warn("harnex: rpc notification handler error: #{e.message}")
432
492
  end
433
493
 
494
+ def handle_jsonl_notification(message)
495
+ event_type = message["type"].to_s
496
+
497
+ case event_type
498
+ when "agent_start", "turn_start"
499
+ @turn_started_seen = true if event_type == "turn_start"
500
+ @state_machine.force_busy!
501
+ emit_event("turn_started") if event_type == "turn_start"
502
+ when "agent_end"
503
+ @last_completed_at = Time.now
504
+ @state_machine.force_prompt!
505
+ emit_event("task_complete")
506
+ adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
507
+ schedule_auto_stop("task_complete")
508
+ when "message_start"
509
+ @pi_streamed_text_by_message[pi_message_key(message["message"])] = false
510
+ when "message_update"
511
+ event = message["assistantMessageEvent"] || {}
512
+ delta = event["delta"]
513
+ key = pi_message_key(message["message"])
514
+ if event["type"] == "text_delta" && delta && !delta.empty?
515
+ @pi_streamed_text_by_message[key] = true
516
+ record_synthesized(delta, newline: false)
517
+ end
518
+ when "message_end"
519
+ key = pi_message_key(message["message"])
520
+ streamed = @pi_streamed_text_by_message.delete(key)
521
+ unless streamed
522
+ text = pi_extract_message_text(message["message"])
523
+ record_synthesized(text) if text
524
+ end
525
+ when "tool_execution_start"
526
+ @event_counters.record_item({ "type" => "dynamicToolCall" })
527
+ record_synthesized(
528
+ "tool: #{message["toolName"] || "tool"}#{message["args"] ? " #{summarize(message["args"])}" : ""}"
529
+ )
530
+ when "tool_execution_end"
531
+ tool_name = message["toolName"] || "tool"
532
+ status = message["isError"] ? "error" : "ok"
533
+ record_synthesized("tool-result: #{tool_name} (#{status})")
534
+ when "compaction_start", "compaction_end"
535
+ emit_event("compaction", reason: message["reason"], phase: event_type)
536
+ when "queue_update"
537
+ nil
538
+ when "auto_retry_start", "auto_retry_end"
539
+ emit_event(event_type, **message.reject { |k, _| k == "type" })
540
+ when "extension_ui_request"
541
+ handle_extension_ui_request(message)
542
+ when "extension_error"
543
+ @last_error = message["error"].to_s unless message["error"].to_s.empty?
544
+ emit_event("extension_error", **message.reject { |k, _| k == "type" })
545
+ when "response"
546
+ # Adapter-level command responses are handled in the adapter.
547
+ nil
548
+ end
549
+ rescue StandardError => e
550
+ warn("harnex: rpc notification handler error: #{e.message}")
551
+ end
552
+
553
+ def handle_extension_ui_request(message)
554
+ method = message["method"].to_s
555
+ request_id = message["id"]
556
+ cancelled = false
557
+ if adapter.respond_to?(:respond_extension_ui_cancel)
558
+ cancelled = adapter.respond_extension_ui_cancel(request_id: request_id, method: method)
559
+ end
560
+
561
+ payload = {
562
+ method: method,
563
+ request_id: request_id,
564
+ auto_cancelled: !!cancelled
565
+ }
566
+ emit_event("extension_ui_request", **payload)
567
+ record_synthesized("extension-ui: #{method}#{cancelled ? " (auto-cancelled)" : ""}")
568
+ end
569
+
570
+ def handle_structured_disconnect(error)
571
+ handle_rpc_disconnect(error)
572
+ end
573
+
434
574
  def handle_rpc_disconnect(error)
435
575
  msg = error.is_a?(Hash) ? error["message"] : error&.message
436
576
  @last_error = msg.to_s unless msg.to_s.empty?
@@ -445,7 +585,7 @@ module Harnex
445
585
  prompt = adapter.initial_prompt
446
586
  return if prompt.to_s.empty?
447
587
 
448
- inject_via_jsonrpc(text: prompt, submit: true, enter_only: false, force: false)
588
+ inject_via_structured(text: prompt, submit: true, enter_only: false, force: false)
449
589
  end
450
590
 
451
591
  def render_item_text(item)
@@ -472,11 +612,37 @@ module Harnex
472
612
  ""
473
613
  end
474
614
 
475
- def record_synthesized(text)
615
+ def pi_extract_message_text(message)
616
+ return nil unless message.is_a?(Hash)
617
+
618
+ content = message["content"]
619
+ case content
620
+ when String
621
+ content
622
+ when Array
623
+ parts = content.filter_map do |item|
624
+ next unless item.is_a?(Hash)
625
+ next unless item["type"] == "text"
626
+
627
+ item["text"].to_s
628
+ end
629
+ parts.empty? ? nil : parts.join
630
+ else
631
+ nil
632
+ end
633
+ end
634
+
635
+ def pi_message_key(message)
636
+ return "unknown" unless message.is_a?(Hash)
637
+
638
+ message["entryId"] || message["id"] || message["timestamp"] || message.object_id
639
+ end
640
+
641
+ def record_synthesized(text, newline: true)
476
642
  return if text.nil? || text.to_s.empty?
477
643
 
478
644
  payload = text.to_s.dup
479
- payload << "\n" unless payload.end_with?("\n")
645
+ payload << "\n" if newline && !payload.end_with?("\n")
480
646
  bytes = payload.b
481
647
  @mutex.synchronize do
482
648
  append_output_log(bytes)
@@ -745,6 +911,7 @@ module Harnex
745
911
  end
746
912
  @exit_reason ||= classify_exit
747
913
  append_summary_record(build_summary_record)
914
+ append_dispatch_history_record
748
915
  emit_summary_event
749
916
  emit_exit_event
750
917
  end
@@ -789,13 +956,59 @@ module Harnex
789
956
  end
790
957
  return unless should_fire
791
958
 
792
- Thread.new do
959
+ thread = Thread.new do
793
960
  begin
794
961
  inject_stop(turn_id: turn_id)
795
962
  rescue StandardError => e
796
963
  warn("harnex: auto-stop failed after #{reason}: #{e.message}")
797
964
  end
798
965
  end
966
+ track_auto_stop_thread(thread)
967
+ end
968
+
969
+ def track_auto_stop_thread(thread)
970
+ @auto_stop_mutex.synchronize { @auto_stop_threads << thread }
971
+ end
972
+
973
+ def drain_auto_stop_threads
974
+ return unless @auto_stop
975
+
976
+ threads = @auto_stop_mutex.synchronize { @auto_stop_threads.dup }
977
+ return if threads.empty?
978
+
979
+ grace_seconds = auto_stop_teardown_grace_seconds
980
+ deadline = Time.now + grace_seconds
981
+ timed_out = []
982
+
983
+ threads.each do |thread|
984
+ remaining = deadline - Time.now
985
+ thread.join(remaining) if remaining.positive?
986
+ timed_out << thread if thread.alive?
987
+ end
988
+ return if timed_out.empty?
989
+
990
+ timed_out.each(&:kill)
991
+ @exit_code = 1 if @exit_code.nil? || @exit_code.zero?
992
+ @term_signal = nil if @exit_code == 1
993
+ emit_event("auto_stop_teardown_timeout", grace_seconds: grace_seconds, threads: timed_out.size)
994
+ end
995
+
996
+ def auto_stop_teardown_grace_seconds
997
+ override = ENV["HARNEX_AUTOSTOP_TEARDOWN_GRACE_SECONDS"]
998
+ return AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT if override.to_s.strip.empty?
999
+
1000
+ Float(override)
1001
+ rescue ArgumentError
1002
+ AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT
1003
+ end
1004
+
1005
+ def normalize_auto_stop_exit_code!
1006
+ return unless @auto_stop
1007
+ return unless @last_completed_at
1008
+ return unless @auto_stop_fired
1009
+
1010
+ @exit_code = 0
1011
+ @term_signal = nil
799
1012
  end
800
1013
 
801
1014
  def classify_exit
@@ -808,7 +1021,7 @@ module Harnex
808
1021
  end
809
1022
 
810
1023
  def boot_failure_exit?
811
- return false unless adapter.transport == :stdio_jsonrpc
1024
+ return false unless structured_transport?
812
1025
  return false if @turn_started_seen
813
1026
 
814
1027
  lifetime = (@ended_at || Time.now) - @started_at
@@ -816,7 +1029,7 @@ module Harnex
816
1029
  end
817
1030
 
818
1031
  def session_summary_present?
819
- @usage_summary.values.any? { |value| !value.nil? }
1032
+ SESSION_SUMMARY_SIGNAL_FIELDS.any? { |field| !@usage_summary[field].nil? }
820
1033
  end
821
1034
 
822
1035
  def build_summary_record
@@ -833,22 +1046,21 @@ module Harnex
833
1046
 
834
1047
  {
835
1048
  id: id,
836
- tmux_session: id,
1049
+ tmux_session: summary_tmux_session,
837
1050
  description: description,
838
1051
  started_at: @started_at.iso8601,
839
1052
  ended_at: @ended_at&.iso8601,
840
1053
  harness: "harnex",
841
1054
  harness_version: Harnex.harness_version,
842
1055
  agent: adapter.key,
843
- agent_version: nil,
844
- agent_provider: nil,
845
- agent_deployment: nil,
1056
+ agent_version: adapter.agent_version,
1057
+ agent_provider: summary_agent_provider,
846
1058
  host: info[:host],
847
1059
  platform: info[:platform],
848
1060
  orchestrator: passthrough["orchestrator"],
849
1061
  orchestrator_session: passthrough["orchestrator_session"],
850
1062
  chain_id: passthrough["chain_id"],
851
- parent_dispatch_id: passthrough["parent_dispatch_id"],
1063
+ parent_dispatch_id: passthrough["parent_dispatch_id"] || @parent_harnex_id,
852
1064
  tier: passthrough["tier"],
853
1065
  phase: passthrough["phase"],
854
1066
  issue: passthrough["issue"],
@@ -858,41 +1070,104 @@ module Harnex
858
1070
  branch: @git_start[:branch],
859
1071
  start_sha: @git_start[:sha],
860
1072
  end_sha: @git_end[:sha]
861
- }
1073
+ }.merge(summary_budget_meta)
862
1074
  end
863
1075
 
864
1076
  def build_summary_actual
865
1077
  counters = @event_counters.snapshot
1078
+ output_measurements = summary_output_measurements
866
1079
  if %w[disconnected boot_failure].include?(@exit_reason)
867
1080
  counters[:disconnections] = [counters[:disconnections], 1].max
868
1081
  end
869
1082
 
870
1083
  actual = {
871
- model: meta_hash["model"],
1084
+ model: summary_model,
872
1085
  effort: meta_hash["effort"],
873
1086
  duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
874
1087
  input_tokens: @usage_summary[:input_tokens],
875
1088
  output_tokens: @usage_summary[:output_tokens],
876
1089
  reasoning_tokens: @usage_summary[:reasoning_tokens],
877
1090
  cached_tokens: @usage_summary[:cached_tokens],
878
- cost_usd: nil,
1091
+ total_tokens: @usage_summary[:total_tokens],
1092
+ cost_usd: @usage_summary[:cost_usd],
1093
+ agent_session_id: summary_agent_session_id,
1094
+ adapter_transport: adapter.transport.to_s,
879
1095
  loc_added: @git_end[:loc_added],
880
1096
  loc_removed: @git_end[:loc_removed],
1097
+ lines_changed: summary_lines_changed,
881
1098
  files_changed: @git_end[:files_changed],
882
1099
  commits: @git_end[:commits],
883
1100
  exit: @exit_reason,
1101
+ task_complete: !!@last_completed_at,
1102
+ signal: @term_signal,
1103
+ exit_code: @exit_code,
1104
+ last_error: @last_error,
884
1105
  stalls: counters[:stalls],
885
1106
  force_resumes: counters[:force_resumes],
886
1107
  disconnections: counters[:disconnections],
887
1108
  compactions: counters[:compactions],
888
- tests_run: nil,
889
- tests_passed: nil,
890
- tests_failed: nil
1109
+ turn_count: @injected_count,
1110
+ tool_calls: summary_tool_calls(counters),
1111
+ commands_executed: counters[:commands_executed],
1112
+ rate_limits: @rate_limits,
1113
+ output_lines: output_measurements[:lines],
1114
+ output_bytes: output_measurements[:bytes],
1115
+ event_records: @events_log_seq,
1116
+ output_log_path: output_log_path,
1117
+ events_log_path: events_log_path
891
1118
  }
892
- actual[:last_error] = @last_error if @exit_reason == "boot_failure" && @last_error
893
1119
  actual
894
1120
  end
895
1121
 
1122
+ def summary_budget_meta
1123
+ BUDGET_META_FIELDS.each_with_object({}) do |field, values|
1124
+ values[field.to_sym] = meta_hash[field] if meta_hash.key?(field)
1125
+ end
1126
+ end
1127
+
1128
+ def summary_lines_changed
1129
+ added = @git_end[:loc_added]
1130
+ removed = @git_end[:loc_removed]
1131
+ return nil if added.nil? && removed.nil?
1132
+
1133
+ added.to_i + removed.to_i
1134
+ end
1135
+
1136
+ def summary_output_measurements
1137
+ size = File.size?(output_log_path)
1138
+ return { lines: nil, bytes: nil } unless size
1139
+
1140
+ lines = 0
1141
+ File.foreach(output_log_path) { lines += 1 }
1142
+ { lines: lines, bytes: size }
1143
+ rescue StandardError
1144
+ { lines: nil, bytes: size }
1145
+ end
1146
+
1147
+ def summary_tmux_session
1148
+ value = load_existing_registry_metadata["tmux_session"]
1149
+ value.to_s.empty? ? nil : value
1150
+ end
1151
+
1152
+ def summary_agent_session_id
1153
+ @usage_summary[:agent_session_id] ||
1154
+ @rpc_thread_id ||
1155
+ (adapter.thread_id if adapter.respond_to?(:thread_id))
1156
+ end
1157
+
1158
+ def summary_agent_provider
1159
+ @usage_summary[:agent_provider] || adapter.provider
1160
+ end
1161
+
1162
+ def summary_model
1163
+ meta_hash["model"] || @usage_summary[:model] ||
1164
+ (adapter.current_model if adapter.respond_to?(:current_model))
1165
+ end
1166
+
1167
+ def summary_tool_calls(counters)
1168
+ @usage_summary[:tool_calls] || counters[:tool_calls]
1169
+ end
1170
+
896
1171
  def summary_predicted_payload
897
1172
  predicted = meta_hash["predicted"]
898
1173
  predicted.is_a?(Hash) ? predicted : {}
@@ -914,18 +1189,25 @@ module Harnex
914
1189
  warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
915
1190
  end
916
1191
 
1192
+ def append_dispatch_history_record
1193
+ path = DispatchHistory.path_for(launch_cwd)
1194
+ DispatchHistory.append(path, DispatchHistory.build_record(self))
1195
+ rescue StandardError => e
1196
+ warn("harnex: failed to write dispatch history: #{e.message}")
1197
+ end
1198
+
917
1199
  def normalized_usage_summary(summary)
918
1200
  summary ||= {}
919
1201
  USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
920
1202
  end
921
1203
 
922
- # Adapters speaking JSON-RPC capture token usage from the structured
923
- # `thread/tokenUsage/updated` notification stream and don't have a
924
- # transcript to scrape; fall back to the schema-true cumulative
925
- # `total` block. Other adapters parse the transcript tail.
1204
+ # Structured adapters emit usage directly (JSON-RPC token snapshots,
1205
+ # Pi RPC stats). PTY adapters parse transcript tails when supported.
926
1206
  def collect_session_summary
927
1207
  if adapter.transport == :stdio_jsonrpc
928
1208
  summary_from_token_usage
1209
+ elsif adapter.respond_to?(:collect_session_summary)
1210
+ adapter.collect_session_summary
929
1211
  else
930
1212
  adapter.parse_session_summary(transcript_tail)
931
1213
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.6.5"
3
- RELEASE_DATE = "2026-05-07"
2
+ VERSION = "0.7.4"
3
+ RELEASE_DATE = "2026-05-25"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -4,6 +4,7 @@ require "open3"
4
4
 
5
5
  require_relative "harnex/version"
6
6
  require_relative "harnex/core"
7
+ require_relative "harnex/dispatch_history"
7
8
  require_relative "harnex/watcher"
8
9
  require_relative "harnex/adapters"
9
10
  require_relative "harnex/runtime/session_state"
@@ -21,6 +22,7 @@ require_relative "harnex/commands/stop"
21
22
  require_relative "harnex/commands/status"
22
23
  require_relative "harnex/commands/logs"
23
24
  require_relative "harnex/commands/events"
25
+ require_relative "harnex/commands/history"
24
26
  require_relative "harnex/commands/pane"
25
27
  require_relative "harnex/commands/recipes"
26
28
  require_relative "harnex/commands/guide"
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.5
4
+ version: 0.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-07 00:00:00.000000000 Z
11
+ date: 2026-05-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
14
- adds a control plane for discovery, messaging, and coordination.
13
+ description: A local PTY harness that wraps terminal AI agents (Claude, Codex, Pi)
14
+ and adds a control plane for discovery, messaging, and coordination.
15
15
  email:
16
16
  - jikkujose@gmail.com
17
17
  executables:
@@ -38,11 +38,15 @@ files:
38
38
  - lib/harnex/adapters/codex.rb
39
39
  - lib/harnex/adapters/codex_appserver.rb
40
40
  - lib/harnex/adapters/generic.rb
41
+ - lib/harnex/adapters/opencode.rb
42
+ - lib/harnex/adapters/pi.rb
41
43
  - lib/harnex/cli.rb
44
+ - lib/harnex/codex/app_server/client.rb
42
45
  - lib/harnex/commands/agents_guide.rb
43
46
  - lib/harnex/commands/doctor.rb
44
47
  - lib/harnex/commands/events.rb
45
48
  - lib/harnex/commands/guide.rb
49
+ - lib/harnex/commands/history.rb
46
50
  - lib/harnex/commands/logs.rb
47
51
  - lib/harnex/commands/pane.rb
48
52
  - lib/harnex/commands/recipes.rb
@@ -54,6 +58,7 @@ files:
54
58
  - lib/harnex/commands/watch.rb
55
59
  - lib/harnex/commands/watch_presets.rb
56
60
  - lib/harnex/core.rb
61
+ - lib/harnex/dispatch_history.rb
57
62
  - lib/harnex/runtime/api_server.rb
58
63
  - lib/harnex/runtime/file_change_hook.rb
59
64
  - lib/harnex/runtime/inbox.rb