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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/README.md +102 -33
- data/TECHNICAL.md +23 -0
- data/guides/01_dispatch.md +24 -21
- data/guides/02_chain.md +6 -3
- data/guides/03_buddy.md +12 -11
- data/guides/04_monitoring.md +17 -16
- data/guides/05_naming.md +16 -15
- data/lib/harnex/adapters/base.rb +36 -2
- 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/pi.rb +512 -0
- data/lib/harnex/adapters.rb +5 -1
- data/lib/harnex/cli.rb +9 -2
- 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 +47 -9
- 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 +326 -44
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +2 -0
- metadata +9 -4
|
@@ -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
|
|
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,
|
|
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
|
|
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] =
|
|
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
|
|
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
|
|
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
|
|
251
|
-
return
|
|
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
|
|
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
|
|
328
|
-
adapter.
|
|
329
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
844
|
-
agent_provider:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
#
|
|
923
|
-
#
|
|
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
|
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,17 +1,17 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
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-
|
|
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)
|
|
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
|