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.
@@ -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 agent_session_id
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 run_jsonrpc if adapter.transport == :stdio_jsonrpc
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] = meta_hash["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 adapter.transport == :stdio_jsonrpc
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 adapter.transport == :stdio_jsonrpc
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 adapter.transport == :stdio_jsonrpc
287
- return inject_via_jsonrpc(text: text, submit: submit, enter_only: enter_only, force: force)
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 inject_via_jsonrpc(text:, submit:, enter_only:, force: false)
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 run_jsonrpc
364
- adapter.on_notification { |msg| handle_rpc_notification(msg) }
365
- adapter.on_disconnect { |err| handle_rpc_disconnect(err) }
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
- inject_via_jsonrpc(text: prompt, submit: true, enter_only: false, force: false)
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 record_synthesized(text)
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" unless payload.end_with?("\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 adapter.transport == :stdio_jsonrpc
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
- @usage_summary.values.any? { |value| !value.nil? }
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: adapter.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: meta_hash["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[:tool_calls],
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
- # 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.
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
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.3"
3
- RELEASE_DATE = "2026-05-13"
2
+ VERSION = "0.7.4"
3
+ RELEASE_DATE = "2026-05-25"
4
4
  end
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.3
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-13 00:00:00.000000000 Z
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) and
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