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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/README.md +71 -21
- data/TECHNICAL.md +23 -0
- data/guides/01_dispatch.md +2 -0
- data/lib/harnex/adapters/base.rb +33 -0
- data/lib/harnex/adapters/claude.rb +4 -0
- data/lib/harnex/adapters/codex.rb +4 -0
- data/lib/harnex/adapters/codex_appserver.rb +56 -230
- data/lib/harnex/adapters/opencode.rb +132 -0
- data/lib/harnex/adapters.rb +3 -1
- data/lib/harnex/cli.rb +8 -1
- data/lib/harnex/codex/app_server/client.rb +348 -0
- data/lib/harnex/commands/doctor.rb +95 -2
- data/lib/harnex/commands/history.rb +149 -0
- data/lib/harnex/commands/run.rb +44 -6
- data/lib/harnex/commands/wait.rb +77 -36
- data/lib/harnex/core.rb +3 -3
- data/lib/harnex/dispatch_history.rb +112 -0
- data/lib/harnex/runtime/session.rb +164 -23
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +6 -2
|
@@ -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,
|
|
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:
|
|
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:
|
|
844
|
-
agent_provider:
|
|
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
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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]] }
|
data/lib/harnex/version.rb
CHANGED
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.
|
|
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-
|
|
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
|