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.
@@ -6,16 +6,20 @@ 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
11
  input_tokens output_tokens reasoning_tokens cached_tokens total_tokens agent_session_id
11
12
  ].freeze
13
+ BUDGET_META_FIELDS = %w[read_budget_lines output_ceiling_lines].freeze
12
14
  class EventCounters
13
15
  def initialize
14
16
  @counts = {
15
17
  stalls: 0,
16
18
  force_resumes: 0,
17
19
  disconnections: 0,
18
- compactions: 0
20
+ compactions: 0,
21
+ tool_calls: 0,
22
+ commands_executed: 0
19
23
  }
20
24
  end
21
25
 
@@ -32,17 +36,31 @@ module Harnex
32
36
  end
33
37
  end
34
38
 
39
+ def record_item(item)
40
+ return unless item.is_a?(Hash)
41
+
42
+ case item["type"]
43
+ when "mcpToolCall", "dynamicToolCall"
44
+ @counts[:tool_calls] += 1
45
+ when "commandExecution"
46
+ @counts[:commands_executed] += 1
47
+ end
48
+ end
49
+
35
50
  def snapshot
36
51
  @counts.dup
37
52
  end
38
53
  end
39
54
 
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
55
+ attr_reader :repo_root, :launch_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
56
+ :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path,
57
+ :started_at, :ended_at, :exit_code, :term_signal
41
58
 
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)
59
+ 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
60
  @adapter = adapter
44
61
  @command = command
45
62
  @repo_root = repo_root
63
+ @launch_cwd = File.expand_path(launch_cwd.to_s.empty? ? repo_root : launch_cwd)
46
64
  @host = host
47
65
  @id = Harnex.normalize_id(id)
48
66
  @watch = watch
@@ -83,6 +101,7 @@ module Harnex
83
101
  @auto_stop = !!auto_stop
84
102
  @auto_stop_fired = false
85
103
  @auto_stop_seen_busy = false
104
+ @auto_stop_threads = []
86
105
  @stop_requested = false
87
106
  @writer = nil
88
107
  @pid = nil
@@ -91,6 +110,9 @@ module Harnex
91
110
  @output_buffer.force_encoding(Encoding::BINARY)
92
111
  @state_machine = SessionState.new(adapter)
93
112
  @inbox = Inbox.new(self, @state_machine, ttl: inbox_ttl)
113
+ @rate_limits = nil
114
+ @parent_harnex_id = ENV["HARNEX_ID"].to_s.strip
115
+ @parent_harnex_id = nil if @parent_harnex_id.empty?
94
116
  end
95
117
 
96
118
  def self.validate_binary!(command)
@@ -145,6 +167,8 @@ module Harnex
145
167
  @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
146
168
  @ended_at = Time.now
147
169
 
170
+ normalize_auto_stop_exit_code!
171
+ drain_auto_stop_threads
148
172
  output_thread.join(1)
149
173
  finalize_session!
150
174
  input_thread&.kill
@@ -200,6 +224,18 @@ module Harnex
200
224
  payload
201
225
  end
202
226
 
227
+ def task_complete?
228
+ !!@last_completed_at
229
+ end
230
+
231
+ def git_start
232
+ @git_start || {}
233
+ end
234
+
235
+ def git_end
236
+ @git_end || {}
237
+ end
238
+
203
239
  def auth_ok?(header)
204
240
  header == "Bearer #{token}"
205
241
  end
@@ -218,14 +254,6 @@ module Harnex
218
254
  return { ok: true, signal: "already_requested" } if stop_requested!
219
255
 
220
256
  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
229
257
  if adapter.respond_to?(:terminate_subprocess)
230
258
  Thread.new do
231
259
  begin
@@ -235,6 +263,14 @@ module Harnex
235
263
  end
236
264
  end
237
265
  end
266
+ @inject_mutex.synchronize do
267
+ begin
268
+ adapter.interrupt(turn_id: turn_id)
269
+ rescue StandardError
270
+ nil
271
+ end
272
+ @state_machine.force_busy!
273
+ end
238
274
  return { ok: true, signal: "interrupt_sent" }
239
275
  end
240
276
 
@@ -359,6 +395,8 @@ module Harnex
359
395
  end
360
396
  @ended_at = Time.now
361
397
 
398
+ normalize_auto_stop_exit_code!
399
+ drain_auto_stop_threads
362
400
  finalize_session!
363
401
  watch_thread&.kill
364
402
  @exit_code
@@ -406,6 +444,7 @@ module Harnex
406
444
  schedule_auto_stop("task_complete", turn_id: payload[:turnId])
407
445
  when "item/completed"
408
446
  emit_event("item_completed", item: params["item"])
447
+ @event_counters.record_item(params["item"])
409
448
  text = render_item_text(params["item"])
410
449
  record_synthesized(text) if text
411
450
  when "thread/compacted"
@@ -745,6 +784,7 @@ module Harnex
745
784
  end
746
785
  @exit_reason ||= classify_exit
747
786
  append_summary_record(build_summary_record)
787
+ append_dispatch_history_record
748
788
  emit_summary_event
749
789
  emit_exit_event
750
790
  end
@@ -789,13 +829,59 @@ module Harnex
789
829
  end
790
830
  return unless should_fire
791
831
 
792
- Thread.new do
832
+ thread = Thread.new do
793
833
  begin
794
834
  inject_stop(turn_id: turn_id)
795
835
  rescue StandardError => e
796
836
  warn("harnex: auto-stop failed after #{reason}: #{e.message}")
797
837
  end
798
838
  end
839
+ track_auto_stop_thread(thread)
840
+ end
841
+
842
+ def track_auto_stop_thread(thread)
843
+ @auto_stop_mutex.synchronize { @auto_stop_threads << thread }
844
+ end
845
+
846
+ def drain_auto_stop_threads
847
+ return unless @auto_stop
848
+
849
+ threads = @auto_stop_mutex.synchronize { @auto_stop_threads.dup }
850
+ return if threads.empty?
851
+
852
+ grace_seconds = auto_stop_teardown_grace_seconds
853
+ deadline = Time.now + grace_seconds
854
+ timed_out = []
855
+
856
+ threads.each do |thread|
857
+ remaining = deadline - Time.now
858
+ thread.join(remaining) if remaining.positive?
859
+ timed_out << thread if thread.alive?
860
+ end
861
+ return if timed_out.empty?
862
+
863
+ timed_out.each(&:kill)
864
+ @exit_code = 1 if @exit_code.nil? || @exit_code.zero?
865
+ @term_signal = nil if @exit_code == 1
866
+ emit_event("auto_stop_teardown_timeout", grace_seconds: grace_seconds, threads: timed_out.size)
867
+ end
868
+
869
+ def auto_stop_teardown_grace_seconds
870
+ override = ENV["HARNEX_AUTOSTOP_TEARDOWN_GRACE_SECONDS"]
871
+ return AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT if override.to_s.strip.empty?
872
+
873
+ Float(override)
874
+ rescue ArgumentError
875
+ AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT
876
+ end
877
+
878
+ def normalize_auto_stop_exit_code!
879
+ return unless @auto_stop
880
+ return unless @last_completed_at
881
+ return unless @auto_stop_fired
882
+
883
+ @exit_code = 0
884
+ @term_signal = nil
799
885
  end
800
886
 
801
887
  def classify_exit
@@ -833,22 +919,21 @@ module Harnex
833
919
 
834
920
  {
835
921
  id: id,
836
- tmux_session: id,
922
+ tmux_session: summary_tmux_session,
837
923
  description: description,
838
924
  started_at: @started_at.iso8601,
839
925
  ended_at: @ended_at&.iso8601,
840
926
  harness: "harnex",
841
927
  harness_version: Harnex.harness_version,
842
928
  agent: adapter.key,
843
- agent_version: nil,
844
- agent_provider: nil,
845
- agent_deployment: nil,
929
+ agent_version: adapter.agent_version,
930
+ agent_provider: adapter.provider,
846
931
  host: info[:host],
847
932
  platform: info[:platform],
848
933
  orchestrator: passthrough["orchestrator"],
849
934
  orchestrator_session: passthrough["orchestrator_session"],
850
935
  chain_id: passthrough["chain_id"],
851
- parent_dispatch_id: passthrough["parent_dispatch_id"],
936
+ parent_dispatch_id: passthrough["parent_dispatch_id"] || @parent_harnex_id,
852
937
  tier: passthrough["tier"],
853
938
  phase: passthrough["phase"],
854
939
  issue: passthrough["issue"],
@@ -858,11 +943,12 @@ module Harnex
858
943
  branch: @git_start[:branch],
859
944
  start_sha: @git_start[:sha],
860
945
  end_sha: @git_end[:sha]
861
- }
946
+ }.merge(summary_budget_meta)
862
947
  end
863
948
 
864
949
  def build_summary_actual
865
950
  counters = @event_counters.snapshot
951
+ output_measurements = summary_output_measurements
866
952
  if %w[disconnected boot_failure].include?(@exit_reason)
867
953
  counters[:disconnections] = [counters[:disconnections], 1].max
868
954
  end
@@ -875,24 +961,72 @@ module Harnex
875
961
  output_tokens: @usage_summary[:output_tokens],
876
962
  reasoning_tokens: @usage_summary[:reasoning_tokens],
877
963
  cached_tokens: @usage_summary[:cached_tokens],
878
- cost_usd: nil,
964
+ total_tokens: @usage_summary[:total_tokens],
965
+ agent_session_id: summary_agent_session_id,
966
+ adapter_transport: adapter.transport.to_s,
879
967
  loc_added: @git_end[:loc_added],
880
968
  loc_removed: @git_end[:loc_removed],
969
+ lines_changed: summary_lines_changed,
881
970
  files_changed: @git_end[:files_changed],
882
971
  commits: @git_end[:commits],
883
972
  exit: @exit_reason,
973
+ task_complete: !!@last_completed_at,
974
+ signal: @term_signal,
975
+ exit_code: @exit_code,
976
+ last_error: @last_error,
884
977
  stalls: counters[:stalls],
885
978
  force_resumes: counters[:force_resumes],
886
979
  disconnections: counters[:disconnections],
887
980
  compactions: counters[:compactions],
888
- tests_run: nil,
889
- tests_passed: nil,
890
- tests_failed: nil
981
+ turn_count: @injected_count,
982
+ tool_calls: counters[:tool_calls],
983
+ commands_executed: counters[:commands_executed],
984
+ rate_limits: @rate_limits,
985
+ output_lines: output_measurements[:lines],
986
+ output_bytes: output_measurements[:bytes],
987
+ event_records: @events_log_seq,
988
+ output_log_path: output_log_path,
989
+ events_log_path: events_log_path
891
990
  }
892
- actual[:last_error] = @last_error if @exit_reason == "boot_failure" && @last_error
893
991
  actual
894
992
  end
895
993
 
994
+ def summary_budget_meta
995
+ BUDGET_META_FIELDS.each_with_object({}) do |field, values|
996
+ values[field.to_sym] = meta_hash[field] if meta_hash.key?(field)
997
+ end
998
+ end
999
+
1000
+ def summary_lines_changed
1001
+ added = @git_end[:loc_added]
1002
+ removed = @git_end[:loc_removed]
1003
+ return nil if added.nil? && removed.nil?
1004
+
1005
+ added.to_i + removed.to_i
1006
+ end
1007
+
1008
+ def summary_output_measurements
1009
+ size = File.size?(output_log_path)
1010
+ return { lines: nil, bytes: nil } unless size
1011
+
1012
+ lines = 0
1013
+ File.foreach(output_log_path) { lines += 1 }
1014
+ { lines: lines, bytes: size }
1015
+ rescue StandardError
1016
+ { lines: nil, bytes: size }
1017
+ end
1018
+
1019
+ def summary_tmux_session
1020
+ value = load_existing_registry_metadata["tmux_session"]
1021
+ value.to_s.empty? ? nil : value
1022
+ end
1023
+
1024
+ def summary_agent_session_id
1025
+ @usage_summary[:agent_session_id] ||
1026
+ @rpc_thread_id ||
1027
+ (adapter.thread_id if adapter.respond_to?(:thread_id))
1028
+ end
1029
+
896
1030
  def summary_predicted_payload
897
1031
  predicted = meta_hash["predicted"]
898
1032
  predicted.is_a?(Hash) ? predicted : {}
@@ -914,6 +1048,13 @@ module Harnex
914
1048
  warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
915
1049
  end
916
1050
 
1051
+ def append_dispatch_history_record
1052
+ path = DispatchHistory.path_for(launch_cwd)
1053
+ DispatchHistory.append(path, DispatchHistory.build_record(self))
1054
+ rescue StandardError => e
1055
+ warn("harnex: failed to write dispatch history: #{e.message}")
1056
+ end
1057
+
917
1058
  def normalized_usage_summary(summary)
918
1059
  summary ||= {}
919
1060
  USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.6.5"
3
- RELEASE_DATE = "2026-05-07"
2
+ VERSION = "0.7.3"
3
+ RELEASE_DATE = "2026-05-13"
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,14 +1,14 @@
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.3
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-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
14
14
  adds a control plane for discovery, messaging, and coordination.
@@ -38,11 +38,14 @@ 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
41
42
  - lib/harnex/cli.rb
43
+ - lib/harnex/codex/app_server/client.rb
42
44
  - lib/harnex/commands/agents_guide.rb
43
45
  - lib/harnex/commands/doctor.rb
44
46
  - lib/harnex/commands/events.rb
45
47
  - lib/harnex/commands/guide.rb
48
+ - lib/harnex/commands/history.rb
46
49
  - lib/harnex/commands/logs.rb
47
50
  - lib/harnex/commands/pane.rb
48
51
  - lib/harnex/commands/recipes.rb
@@ -54,6 +57,7 @@ files:
54
57
  - lib/harnex/commands/watch.rb
55
58
  - lib/harnex/commands/watch_presets.rb
56
59
  - lib/harnex/core.rb
60
+ - lib/harnex/dispatch_history.rb
57
61
  - lib/harnex/runtime/api_server.rb
58
62
  - lib/harnex/runtime/file_change_hook.rb
59
63
  - lib/harnex/runtime/inbox.rb