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.
@@ -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)
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
@@ -60,6 +78,8 @@ module Harnex
60
78
  @mutex = Mutex.new
61
79
  @inject_mutex = Mutex.new
62
80
  @events_mutex = Mutex.new
81
+ @stop_mutex = Mutex.new
82
+ @auto_stop_mutex = Mutex.new
63
83
  @injected_count = 0
64
84
  @last_injected_at = nil
65
85
  @started_at = Time.now
@@ -74,8 +94,15 @@ module Harnex
74
94
  @usage_summary = {}
75
95
  @ended_at = nil
76
96
  @exit_reason = nil
97
+ @last_error = nil
98
+ @session_finalized = false
77
99
  @turn_started_seen = false
78
100
  @last_completed_at = nil
101
+ @auto_stop = !!auto_stop
102
+ @auto_stop_fired = false
103
+ @auto_stop_seen_busy = false
104
+ @auto_stop_threads = []
105
+ @stop_requested = false
79
106
  @writer = nil
80
107
  @pid = nil
81
108
  @term_signal = nil
@@ -83,6 +110,9 @@ module Harnex
83
110
  @output_buffer.force_encoding(Encoding::BINARY)
84
111
  @state_machine = SessionState.new(adapter)
85
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?
86
116
  end
87
117
 
88
118
  def self.validate_binary!(command)
@@ -116,6 +146,7 @@ module Harnex
116
146
  def run_pty
117
147
  @reader, @writer, @pid = PTY.spawn(child_env, *command)
118
148
  @writer.sync = true
149
+ arm_auto_stop_after_initial_context
119
150
  emit_started_event
120
151
  emit_git_start_event
121
152
 
@@ -136,17 +167,15 @@ module Harnex
136
167
  @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
137
168
  @ended_at = Time.now
138
169
 
170
+ normalize_auto_stop_exit_code!
171
+ drain_auto_stop_threads
139
172
  output_thread.join(1)
140
- emit_session_end_telemetry
141
- @exit_reason = classify_exit
142
- summary_record = build_summary_record
143
- append_summary_record(summary_record)
144
- emit_summary_event
145
- emit_exit_event
173
+ finalize_session!
146
174
  input_thread&.kill
147
175
  watch_thread&.kill
148
176
  @exit_code
149
177
  ensure
178
+ finalize_session!
150
179
  @inbox.stop
151
180
  STDIN.cooked! if STDIN.tty? && stdin_state
152
181
  @server&.stop
@@ -195,6 +224,18 @@ module Harnex
195
224
  payload
196
225
  end
197
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
+
198
239
  def auth_ok?(header)
199
240
  header == "Bearer #{token}"
200
241
  end
@@ -205,11 +246,26 @@ module Harnex
205
246
  inject_sequence([{ text: text, newline: newline }])
206
247
  end
207
248
 
208
- def inject_stop
249
+ def inject_stop(turn_id: nil)
250
+ unless adapter.transport == :stdio_jsonrpc
251
+ raise "session is not running" unless pid && Harnex.alive_pid?(pid)
252
+ end
253
+
254
+ return { ok: true, signal: "already_requested" } if stop_requested!
255
+
209
256
  if adapter.transport == :stdio_jsonrpc
257
+ if adapter.respond_to?(:terminate_subprocess)
258
+ Thread.new do
259
+ begin
260
+ adapter.terminate_subprocess
261
+ rescue Errno::ESRCH, StandardError
262
+ nil
263
+ end
264
+ end
265
+ end
210
266
  @inject_mutex.synchronize do
211
267
  begin
212
- adapter.interrupt
268
+ adapter.interrupt(turn_id: turn_id)
213
269
  rescue StandardError
214
270
  nil
215
271
  end
@@ -218,8 +274,6 @@ module Harnex
218
274
  return { ok: true, signal: "interrupt_sent" }
219
275
  end
220
276
 
221
- raise "session is not running" unless pid && Harnex.alive_pid?(pid)
222
-
223
277
  @inject_mutex.synchronize do
224
278
  adapter.inject_exit(@writer)
225
279
  @state_machine.force_busy!
@@ -341,15 +395,13 @@ module Harnex
341
395
  end
342
396
  @ended_at = Time.now
343
397
 
344
- emit_session_end_telemetry
345
- @exit_reason = classify_exit
346
- summary_record = build_summary_record
347
- append_summary_record(summary_record)
348
- emit_summary_event
349
- emit_exit_event
398
+ normalize_auto_stop_exit_code!
399
+ drain_auto_stop_threads
400
+ finalize_session!
350
401
  watch_thread&.kill
351
402
  @exit_code
352
403
  ensure
404
+ finalize_session!
353
405
  @inbox.stop
354
406
  @server&.stop
355
407
  begin
@@ -376,33 +428,40 @@ module Harnex
376
428
 
377
429
  case method
378
430
  when "thread/started"
379
- @rpc_thread_id = params["threadId"] || params["thread_id"]
431
+ @rpc_thread_id = params.dig("thread", "id")
380
432
  when "turn/started"
381
433
  @turn_started_seen = true
382
434
  @state_machine.force_busy!
383
- emit_event("turn_started", turnId: params["turnId"] || params["turn_id"])
435
+ emit_event("turn_started", turnId: params.dig("turn", "id"))
384
436
  when "turn/completed"
385
437
  @last_completed_at = Time.now
386
438
  @state_machine.force_prompt!
387
- payload = { turnId: params["turnId"] || params["turn_id"] }
388
- payload[:status] = params["status"] if params["status"]
439
+ turn = params["turn"] || {}
440
+ payload = { turnId: turn["id"] }
441
+ payload[:status] = turn["status"] if turn["status"]
389
442
  payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
390
443
  emit_event("task_complete", **payload)
444
+ schedule_auto_stop("task_complete", turn_id: payload[:turnId])
391
445
  when "item/completed"
392
446
  emit_event("item_completed", item: params["item"])
447
+ @event_counters.record_item(params["item"])
393
448
  text = render_item_text(params["item"])
394
449
  record_synthesized(text) if text
395
450
  when "thread/compacted"
396
451
  emit_event("compaction", **params)
397
452
  when "thread/tokenUsage/updated"
398
- # Surfaced via status fields in Phase 4; no event spam.
399
- @token_usage = params["usage"] || params
453
+ # Schema: ThreadTokenUsageUpdatedNotification carries
454
+ # `tokenUsage: { last, total, modelContextWindow? }` where each
455
+ # breakdown has camelCase {input,output,cachedInput,reasoningOutput,total}Tokens.
456
+ # Snapshot it; the cumulative `total` is read at session end.
457
+ @token_usage = params["tokenUsage"] if params["tokenUsage"].is_a?(Hash)
400
458
  when "thread/status/changed"
401
459
  # State machine reflects RPC state; no event needed.
402
460
  nil
403
461
  when "account/rateLimits/updated"
404
462
  @rate_limits = params
405
463
  when "error"
464
+ @last_error = params["message"].to_s unless params["message"].to_s.empty?
406
465
  @state_machine.force_busy!
407
466
  emit_event("disconnected", source: "error_notification", message: params["message"])
408
467
  signal_rpc_done!
@@ -413,6 +472,7 @@ module Harnex
413
472
 
414
473
  def handle_rpc_disconnect(error)
415
474
  msg = error.is_a?(Hash) ? error["message"] : error&.message
475
+ @last_error = msg.to_s unless msg.to_s.empty?
416
476
  @state_machine.force_busy!
417
477
  emit_event("disconnected", source: "transport", message: msg) rescue nil
418
478
  signal_rpc_done!
@@ -430,14 +490,15 @@ module Harnex
430
490
  def render_item_text(item)
431
491
  return nil unless item.is_a?(Hash)
432
492
 
433
- type = item["type"] || item["kind"]
434
- case type
435
- when "agent_message", "assistant_message"
436
- item["text"] || item.dig("message", "text")
437
- when "tool_call"
438
- name = item["name"] || item.dig("tool", "name") || "tool"
439
- params = item["params"] || item["arguments"]
440
- "tool: #{name}#{params ? " #{summarize(params)}" : ""}"
493
+ case item["type"]
494
+ when "agentMessage"
495
+ item["text"]
496
+ when "mcpToolCall", "dynamicToolCall"
497
+ name = item["tool"] || "tool"
498
+ args = item["arguments"]
499
+ "tool: #{name}#{args ? " #{summarize(args)}" : ""}"
500
+ when "commandExecution"
501
+ "command: #{item["command"]}"
441
502
  else
442
503
  item["text"]
443
504
  end
@@ -644,7 +705,9 @@ module Harnex
644
705
  @output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
645
706
  @output_buffer.dup
646
707
  end
647
- @state_machine.update(snapshot)
708
+ old_state = @state_machine.to_s.to_sym
709
+ new_state = @state_machine.update(snapshot)
710
+ handle_auto_stop_pty_transition(old_state, new_state)
648
711
  end
649
712
 
650
713
  def append_output_log(chunk)
@@ -679,7 +742,7 @@ module Harnex
679
742
  end
680
743
 
681
744
  def emit_session_end_telemetry
682
- @usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
745
+ @usage_summary = normalized_usage_summary(collect_session_summary)
683
746
  emit_event("usage", **@usage_summary)
684
747
 
685
748
  @git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
@@ -707,6 +770,120 @@ module Harnex
707
770
  emit_event("exited", **payload)
708
771
  end
709
772
 
773
+ def finalize_session!
774
+ return if @session_finalized
775
+ return unless @events_log
776
+
777
+ @session_finalized = true
778
+ @ended_at ||= Time.now
779
+ begin
780
+ emit_session_end_telemetry
781
+ rescue StandardError => e
782
+ @usage_summary = normalized_usage_summary(nil)
783
+ warn("harnex: failed to collect session-end telemetry: #{e.message}")
784
+ end
785
+ @exit_reason ||= classify_exit
786
+ append_summary_record(build_summary_record)
787
+ append_dispatch_history_record
788
+ emit_summary_event
789
+ emit_exit_event
790
+ end
791
+
792
+ def stop_requested!
793
+ @stop_mutex.synchronize do
794
+ return true if @stop_requested
795
+
796
+ @stop_requested = true
797
+ false
798
+ end
799
+ end
800
+
801
+ def arm_auto_stop_after_initial_context
802
+ return unless @auto_stop
803
+ return unless adapter.transport == :pty
804
+
805
+ @auto_stop_mutex.synchronize { @auto_stop_seen_busy = true }
806
+ @state_machine.force_busy!
807
+ end
808
+
809
+ def handle_auto_stop_pty_transition(old_state, new_state)
810
+ return unless @auto_stop
811
+ return unless adapter.transport == :pty
812
+
813
+ seen_busy = @auto_stop_mutex.synchronize do
814
+ @auto_stop_seen_busy ||= old_state == :busy || new_state == :busy
815
+ end
816
+ schedule_auto_stop("prompt_after_busy") if seen_busy && new_state == :prompt
817
+ end
818
+
819
+ def schedule_auto_stop(reason, turn_id: nil)
820
+ return unless @auto_stop
821
+
822
+ should_fire = @auto_stop_mutex.synchronize do
823
+ if @auto_stop_fired
824
+ false
825
+ else
826
+ @auto_stop_fired = true
827
+ true
828
+ end
829
+ end
830
+ return unless should_fire
831
+
832
+ thread = Thread.new do
833
+ begin
834
+ inject_stop(turn_id: turn_id)
835
+ rescue StandardError => e
836
+ warn("harnex: auto-stop failed after #{reason}: #{e.message}")
837
+ end
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
885
+ end
886
+
710
887
  def classify_exit
711
888
  return "timeout" if @exit_code == 124
712
889
  return "success" if @exit_code == 0 && session_summary_present?
@@ -742,22 +919,21 @@ module Harnex
742
919
 
743
920
  {
744
921
  id: id,
745
- tmux_session: id,
922
+ tmux_session: summary_tmux_session,
746
923
  description: description,
747
924
  started_at: @started_at.iso8601,
748
925
  ended_at: @ended_at&.iso8601,
749
926
  harness: "harnex",
750
927
  harness_version: Harnex.harness_version,
751
928
  agent: adapter.key,
752
- agent_version: nil,
753
- agent_provider: nil,
754
- agent_deployment: nil,
929
+ agent_version: adapter.agent_version,
930
+ agent_provider: adapter.provider,
755
931
  host: info[:host],
756
932
  platform: info[:platform],
757
933
  orchestrator: passthrough["orchestrator"],
758
934
  orchestrator_session: passthrough["orchestrator_session"],
759
935
  chain_id: passthrough["chain_id"],
760
- parent_dispatch_id: passthrough["parent_dispatch_id"],
936
+ parent_dispatch_id: passthrough["parent_dispatch_id"] || @parent_harnex_id,
761
937
  tier: passthrough["tier"],
762
938
  phase: passthrough["phase"],
763
939
  issue: passthrough["issue"],
@@ -767,16 +943,17 @@ module Harnex
767
943
  branch: @git_start[:branch],
768
944
  start_sha: @git_start[:sha],
769
945
  end_sha: @git_end[:sha]
770
- }
946
+ }.merge(summary_budget_meta)
771
947
  end
772
948
 
773
949
  def build_summary_actual
774
950
  counters = @event_counters.snapshot
951
+ output_measurements = summary_output_measurements
775
952
  if %w[disconnected boot_failure].include?(@exit_reason)
776
953
  counters[:disconnections] = [counters[:disconnections], 1].max
777
954
  end
778
955
 
779
- {
956
+ actual = {
780
957
  model: meta_hash["model"],
781
958
  effort: meta_hash["effort"],
782
959
  duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
@@ -784,20 +961,70 @@ module Harnex
784
961
  output_tokens: @usage_summary[:output_tokens],
785
962
  reasoning_tokens: @usage_summary[:reasoning_tokens],
786
963
  cached_tokens: @usage_summary[:cached_tokens],
787
- 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,
788
967
  loc_added: @git_end[:loc_added],
789
968
  loc_removed: @git_end[:loc_removed],
969
+ lines_changed: summary_lines_changed,
790
970
  files_changed: @git_end[:files_changed],
791
971
  commits: @git_end[:commits],
792
972
  exit: @exit_reason,
973
+ task_complete: !!@last_completed_at,
974
+ signal: @term_signal,
975
+ exit_code: @exit_code,
976
+ last_error: @last_error,
793
977
  stalls: counters[:stalls],
794
978
  force_resumes: counters[:force_resumes],
795
979
  disconnections: counters[:disconnections],
796
980
  compactions: counters[:compactions],
797
- tests_run: nil,
798
- tests_passed: nil,
799
- 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
800
990
  }
991
+ actual
992
+ end
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))
801
1028
  end
802
1029
 
803
1030
  def summary_predicted_payload
@@ -821,11 +1048,45 @@ module Harnex
821
1048
  warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
822
1049
  end
823
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
+
824
1058
  def normalized_usage_summary(summary)
825
1059
  summary ||= {}
826
1060
  USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
827
1061
  end
828
1062
 
1063
+ # Adapters speaking JSON-RPC capture token usage from the structured
1064
+ # `thread/tokenUsage/updated` notification stream and don't have a
1065
+ # transcript to scrape; fall back to the schema-true cumulative
1066
+ # `total` block. Other adapters parse the transcript tail.
1067
+ def collect_session_summary
1068
+ if adapter.transport == :stdio_jsonrpc
1069
+ summary_from_token_usage
1070
+ else
1071
+ adapter.parse_session_summary(transcript_tail)
1072
+ end
1073
+ end
1074
+
1075
+ def summary_from_token_usage
1076
+ return {} unless @token_usage.is_a?(Hash)
1077
+
1078
+ total = @token_usage["total"]
1079
+ return {} unless total.is_a?(Hash)
1080
+
1081
+ {
1082
+ input_tokens: total["inputTokens"],
1083
+ output_tokens: total["outputTokens"],
1084
+ reasoning_tokens: total["reasoningOutputTokens"],
1085
+ cached_tokens: total["cachedInputTokens"],
1086
+ total_tokens: total["totalTokens"]
1087
+ }
1088
+ end
1089
+
829
1090
  def transcript_tail
830
1091
  return "" unless File.file?(output_log_path)
831
1092
 
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.6.4"
3
- RELEASE_DATE = "2026-05-06"
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.4
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-06 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