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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +170 -0
- data/README.md +71 -21
- data/TECHNICAL.md +24 -0
- data/guides/01_dispatch.md +22 -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 +85 -200
- data/lib/harnex/adapters/generic.rb +11 -0
- 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 +62 -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 +307 -46
- 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)
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
388
|
-
payload
|
|
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
|
-
#
|
|
399
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
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(
|
|
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:
|
|
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:
|
|
753
|
-
agent_provider:
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
|
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
|