harnex 0.7.3 → 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 +20 -0
- data/README.md +55 -36
- data/guides/01_dispatch.md +24 -23
- 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 +3 -2
- data/lib/harnex/adapters/pi.rb +512 -0
- data/lib/harnex/adapters.rb +3 -1
- data/lib/harnex/cli.rb +1 -1
- data/lib/harnex/commands/run.rb +3 -3
- data/lib/harnex/runtime/session.rb +164 -23
- data/lib/harnex/version.rb +2 -2
- metadata +5 -4
|
@@ -8,7 +8,12 @@ module Harnex
|
|
|
8
8
|
TRANSCRIPT_TAIL_BYTES = 16 * 1024
|
|
9
9
|
AUTOSTOP_TEARDOWN_GRACE_SECONDS_DEFAULT = 5.0
|
|
10
10
|
USAGE_FIELDS = %i[
|
|
11
|
-
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
|
|
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
|
|
12
17
|
].freeze
|
|
13
18
|
BUDGET_META_FIELDS = %w[read_budget_lines output_ceiling_lines].freeze
|
|
14
19
|
class EventCounters
|
|
@@ -98,6 +103,7 @@ module Harnex
|
|
|
98
103
|
@session_finalized = false
|
|
99
104
|
@turn_started_seen = false
|
|
100
105
|
@last_completed_at = nil
|
|
106
|
+
@pi_streamed_text_by_message = {}
|
|
101
107
|
@auto_stop = !!auto_stop
|
|
102
108
|
@auto_stop_fired = false
|
|
103
109
|
@auto_stop_seen_busy = false
|
|
@@ -138,7 +144,7 @@ module Harnex
|
|
|
138
144
|
prepare_output_log
|
|
139
145
|
prepare_events_log
|
|
140
146
|
|
|
141
|
-
return
|
|
147
|
+
return run_structured if structured_transport?
|
|
142
148
|
|
|
143
149
|
run_pty
|
|
144
150
|
end
|
|
@@ -218,7 +224,7 @@ module Harnex
|
|
|
218
224
|
payload[:agent_state] = @state_machine.to_s
|
|
219
225
|
payload[:inbox] = @inbox.stats
|
|
220
226
|
payload[:last_completed_at] = @last_completed_at&.iso8601
|
|
221
|
-
payload[:model] =
|
|
227
|
+
payload[:model] = summary_model
|
|
222
228
|
payload[:effort] = meta_hash["effort"]
|
|
223
229
|
payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
|
|
224
230
|
payload
|
|
@@ -247,13 +253,13 @@ module Harnex
|
|
|
247
253
|
end
|
|
248
254
|
|
|
249
255
|
def inject_stop(turn_id: nil)
|
|
250
|
-
unless
|
|
256
|
+
unless structured_transport?
|
|
251
257
|
raise "session is not running" unless pid && Harnex.alive_pid?(pid)
|
|
252
258
|
end
|
|
253
259
|
|
|
254
260
|
return { ok: true, signal: "already_requested" } if stop_requested!
|
|
255
261
|
|
|
256
|
-
if
|
|
262
|
+
if structured_transport?
|
|
257
263
|
if adapter.respond_to?(:terminate_subprocess)
|
|
258
264
|
Thread.new do
|
|
259
265
|
begin
|
|
@@ -283,8 +289,8 @@ module Harnex
|
|
|
283
289
|
end
|
|
284
290
|
|
|
285
291
|
def inject_via_adapter(text:, submit:, enter_only:, force: false)
|
|
286
|
-
if
|
|
287
|
-
return
|
|
292
|
+
if structured_transport?
|
|
293
|
+
return inject_via_structured(text: text, submit: submit, enter_only: enter_only, force: force)
|
|
288
294
|
end
|
|
289
295
|
|
|
290
296
|
snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
|
|
@@ -311,7 +317,7 @@ module Harnex
|
|
|
311
317
|
.tap { emit_send_event(text, force: payload[:force]) }
|
|
312
318
|
end
|
|
313
319
|
|
|
314
|
-
def
|
|
320
|
+
def inject_via_structured(text:, submit:, enter_only:, force: false)
|
|
315
321
|
payload = adapter.build_send_payload(
|
|
316
322
|
text: text,
|
|
317
323
|
submit: submit,
|
|
@@ -360,9 +366,13 @@ module Harnex
|
|
|
360
366
|
|
|
361
367
|
private
|
|
362
368
|
|
|
363
|
-
def
|
|
364
|
-
adapter.
|
|
365
|
-
|
|
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) }
|
|
366
376
|
|
|
367
377
|
adapter.start_rpc(env: child_env, cwd: repo_root)
|
|
368
378
|
@pid = adapter.pid
|
|
@@ -415,6 +425,8 @@ module Harnex
|
|
|
415
425
|
@events_log&.close unless @events_log&.closed?
|
|
416
426
|
end
|
|
417
427
|
|
|
428
|
+
alias run_jsonrpc run_structured
|
|
429
|
+
|
|
418
430
|
def signal_rpc_done!
|
|
419
431
|
@rpc_done = true
|
|
420
432
|
if defined?(@rpc_done_lock) && @rpc_done_lock
|
|
@@ -422,6 +434,15 @@ module Harnex
|
|
|
422
434
|
end
|
|
423
435
|
end
|
|
424
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
|
+
|
|
425
446
|
def handle_rpc_notification(message)
|
|
426
447
|
method = message["method"]
|
|
427
448
|
params = message["params"] || {}
|
|
@@ -470,6 +491,86 @@ module Harnex
|
|
|
470
491
|
warn("harnex: rpc notification handler error: #{e.message}")
|
|
471
492
|
end
|
|
472
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
|
+
|
|
473
574
|
def handle_rpc_disconnect(error)
|
|
474
575
|
msg = error.is_a?(Hash) ? error["message"] : error&.message
|
|
475
576
|
@last_error = msg.to_s unless msg.to_s.empty?
|
|
@@ -484,7 +585,7 @@ module Harnex
|
|
|
484
585
|
prompt = adapter.initial_prompt
|
|
485
586
|
return if prompt.to_s.empty?
|
|
486
587
|
|
|
487
|
-
|
|
588
|
+
inject_via_structured(text: prompt, submit: true, enter_only: false, force: false)
|
|
488
589
|
end
|
|
489
590
|
|
|
490
591
|
def render_item_text(item)
|
|
@@ -511,11 +612,37 @@ module Harnex
|
|
|
511
612
|
""
|
|
512
613
|
end
|
|
513
614
|
|
|
514
|
-
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)
|
|
515
642
|
return if text.nil? || text.to_s.empty?
|
|
516
643
|
|
|
517
644
|
payload = text.to_s.dup
|
|
518
|
-
payload << "\n"
|
|
645
|
+
payload << "\n" if newline && !payload.end_with?("\n")
|
|
519
646
|
bytes = payload.b
|
|
520
647
|
@mutex.synchronize do
|
|
521
648
|
append_output_log(bytes)
|
|
@@ -894,7 +1021,7 @@ module Harnex
|
|
|
894
1021
|
end
|
|
895
1022
|
|
|
896
1023
|
def boot_failure_exit?
|
|
897
|
-
return false unless
|
|
1024
|
+
return false unless structured_transport?
|
|
898
1025
|
return false if @turn_started_seen
|
|
899
1026
|
|
|
900
1027
|
lifetime = (@ended_at || Time.now) - @started_at
|
|
@@ -902,7 +1029,7 @@ module Harnex
|
|
|
902
1029
|
end
|
|
903
1030
|
|
|
904
1031
|
def session_summary_present?
|
|
905
|
-
|
|
1032
|
+
SESSION_SUMMARY_SIGNAL_FIELDS.any? { |field| !@usage_summary[field].nil? }
|
|
906
1033
|
end
|
|
907
1034
|
|
|
908
1035
|
def build_summary_record
|
|
@@ -927,7 +1054,7 @@ module Harnex
|
|
|
927
1054
|
harness_version: Harnex.harness_version,
|
|
928
1055
|
agent: adapter.key,
|
|
929
1056
|
agent_version: adapter.agent_version,
|
|
930
|
-
agent_provider:
|
|
1057
|
+
agent_provider: summary_agent_provider,
|
|
931
1058
|
host: info[:host],
|
|
932
1059
|
platform: info[:platform],
|
|
933
1060
|
orchestrator: passthrough["orchestrator"],
|
|
@@ -954,7 +1081,7 @@ module Harnex
|
|
|
954
1081
|
end
|
|
955
1082
|
|
|
956
1083
|
actual = {
|
|
957
|
-
model:
|
|
1084
|
+
model: summary_model,
|
|
958
1085
|
effort: meta_hash["effort"],
|
|
959
1086
|
duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
|
|
960
1087
|
input_tokens: @usage_summary[:input_tokens],
|
|
@@ -962,6 +1089,7 @@ module Harnex
|
|
|
962
1089
|
reasoning_tokens: @usage_summary[:reasoning_tokens],
|
|
963
1090
|
cached_tokens: @usage_summary[:cached_tokens],
|
|
964
1091
|
total_tokens: @usage_summary[:total_tokens],
|
|
1092
|
+
cost_usd: @usage_summary[:cost_usd],
|
|
965
1093
|
agent_session_id: summary_agent_session_id,
|
|
966
1094
|
adapter_transport: adapter.transport.to_s,
|
|
967
1095
|
loc_added: @git_end[:loc_added],
|
|
@@ -979,7 +1107,7 @@ module Harnex
|
|
|
979
1107
|
disconnections: counters[:disconnections],
|
|
980
1108
|
compactions: counters[:compactions],
|
|
981
1109
|
turn_count: @injected_count,
|
|
982
|
-
tool_calls: counters
|
|
1110
|
+
tool_calls: summary_tool_calls(counters),
|
|
983
1111
|
commands_executed: counters[:commands_executed],
|
|
984
1112
|
rate_limits: @rate_limits,
|
|
985
1113
|
output_lines: output_measurements[:lines],
|
|
@@ -1027,6 +1155,19 @@ module Harnex
|
|
|
1027
1155
|
(adapter.thread_id if adapter.respond_to?(:thread_id))
|
|
1028
1156
|
end
|
|
1029
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
|
+
|
|
1030
1171
|
def summary_predicted_payload
|
|
1031
1172
|
predicted = meta_hash["predicted"]
|
|
1032
1173
|
predicted.is_a?(Hash) ? predicted : {}
|
|
@@ -1060,13 +1201,13 @@ module Harnex
|
|
|
1060
1201
|
USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
|
|
1061
1202
|
end
|
|
1062
1203
|
|
|
1063
|
-
#
|
|
1064
|
-
#
|
|
1065
|
-
# transcript to scrape; fall back to the schema-true cumulative
|
|
1066
|
-
# `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.
|
|
1067
1206
|
def collect_session_summary
|
|
1068
1207
|
if adapter.transport == :stdio_jsonrpc
|
|
1069
1208
|
summary_from_token_usage
|
|
1209
|
+
elsif adapter.respond_to?(:collect_session_summary)
|
|
1210
|
+
adapter.collect_session_summary
|
|
1070
1211
|
else
|
|
1071
1212
|
adapter.parse_session_summary(transcript_tail)
|
|
1072
1213
|
end
|
data/lib/harnex/version.rb
CHANGED
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.7.
|
|
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:
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- lib/harnex/adapters/codex_appserver.rb
|
|
40
40
|
- lib/harnex/adapters/generic.rb
|
|
41
41
|
- lib/harnex/adapters/opencode.rb
|
|
42
|
+
- lib/harnex/adapters/pi.rb
|
|
42
43
|
- lib/harnex/cli.rb
|
|
43
44
|
- lib/harnex/codex/app_server/client.rb
|
|
44
45
|
- lib/harnex/commands/agents_guide.rb
|