harnex 0.29.0 → 0.30.0

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.
@@ -61,6 +61,7 @@ module Harnex
61
61
  MAX_PENDING_PI_COMMANDS = 256
62
62
  # Inline cap for a turn's final reply; the full text is in its file.
63
63
  FINAL_MESSAGE_LIMIT = 4000
64
+ MAX_EARLIER_REPLIES = 3
64
65
  class EventCounters
65
66
  def initialize
66
67
  @counts = {
@@ -171,6 +172,7 @@ module Harnex
171
172
  @turn_follow_ups_sent = 0
172
173
  @pi_follow_up_pending = 0
173
174
  @turn_final_text = nil
175
+ @turn_earlier_replies = []
174
176
  @completion_notifier = completion_notifier || CompletionNotifier.new(
175
177
  repo_root: repo_root,
176
178
  id: @id,
@@ -190,6 +192,7 @@ module Harnex
190
192
  @observed_commands_mutex = Mutex.new
191
193
  @observed_commands = []
192
194
  @pi_pending_commands = {}
195
+ @pi_git_heads = {}
193
196
  @pi_unobserved_commands = 0
194
197
  # Final gates and the failure flag cover the whole command history; the
195
198
  # retained @observed_commands window is capped at MAX_COMMANDS (#68).
@@ -480,10 +483,12 @@ module Harnex
480
483
  { ok: true, signal: "exit_sequence_sent" }
481
484
  end
482
485
 
483
- def inject_via_adapter(text:, submit:, enter_only:, force: false, follow_up: false)
486
+ # `from` names who sent a message through `harnex send` (#92); the
487
+ # launch prompt has none.
488
+ def inject_via_adapter(text:, submit:, enter_only:, force: false, follow_up: false, from: nil)
484
489
  if structured_transport?
485
490
  return inject_via_structured(text: text, submit: submit, enter_only: enter_only, force: force,
486
- follow_up: follow_up)
491
+ follow_up: follow_up, from: from)
487
492
  end
488
493
 
489
494
  after_seq = current_event_seq
@@ -509,7 +514,7 @@ module Harnex
509
514
  force: payload[:force],
510
515
  after_seq: after_seq
511
516
  )
512
- .tap { emit_send_event(text, force: payload[:force]) }
517
+ .tap { emit_send_event(text, force: payload[:force], from: from) }
513
518
  end
514
519
 
515
520
  # #79: a structured adapter that can queue a message into the running turn
@@ -525,7 +530,7 @@ module Harnex
525
530
  @events_mutex.synchronize { @events_log_seq }
526
531
  end
527
532
 
528
- def inject_via_structured(text:, submit:, enter_only:, force: false, follow_up: false)
533
+ def inject_via_structured(text:, submit:, enter_only:, force: false, follow_up: false, from: nil)
529
534
  if @runtime_budget&.expired?
530
535
  stop_for_runtime_budget!
531
536
  raise ArgumentError, "session runtime budget is exhausted"
@@ -560,6 +565,8 @@ module Harnex
560
565
  # is reset here. If Pi has in fact gone idle it starts a new run,
561
566
  # whose agent_start begins the next turn.
562
567
  unless queued_into_run
568
+ # A new prompt answers nothing earlier in this turn (#92).
569
+ @pi_last_answer = nil
563
570
  # Reserve work before the blocking RPC. Steering an active run does
564
571
  # not reset its clocks, but a new prompt cannot reuse old proof.
565
572
  @activity.start_turn
@@ -584,7 +591,7 @@ module Harnex
584
591
  end
585
592
  end
586
593
 
587
- emit_send_event(dispatch_options.fetch(:prompt, text), force: payload[:force])
594
+ emit_send_event(dispatch_options.fetch(:prompt, text), force: payload[:force], from: from)
588
595
  {
589
596
  ok: true,
590
597
  cli: adapter.key,
@@ -930,6 +937,7 @@ module Harnex
930
937
  adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
931
938
  schedule_auto_stop("agent_settled", interrupt: false) if task_complete? || task_failed?
932
939
  when "message_start"
940
+ note_pi_user_message(message["message"])
933
941
  @pi_active_message_key = pi_message_key(message["message"])
934
942
  @pi_streamed_text_by_message[@pi_active_message_key] = false
935
943
  when "message_update"
@@ -955,6 +963,7 @@ module Harnex
955
963
  if pi_command_tool?(message)
956
964
  @event_counters.record_item({ "type" => "commandExecution" })
957
965
  start_pi_command(message)
966
+ note_pi_git_head(message)
958
967
  else
959
968
  @event_counters.record_item({ "type" => "dynamicToolCall" })
960
969
  end
@@ -962,7 +971,10 @@ module Harnex
962
971
  "tool: #{message["toolName"] || "tool"}#{message["args"] ? " #{summarize(message["args"])}" : ""}"
963
972
  )
964
973
  when "tool_execution_end"
965
- finish_pi_command(message) if pi_command_tool?(message)
974
+ if pi_command_tool?(message)
975
+ finish_pi_command(message)
976
+ record_pi_worker_commits(message)
977
+ end
966
978
  tool_name = message["toolName"] || "tool"
967
979
  status = message["isError"] ? "error" : "ok"
968
980
  record_synthesized("tool-result: #{tool_name} (#{status})")
@@ -1198,6 +1210,9 @@ module Harnex
1198
1210
 
1199
1211
  text = pi_extract_message_text(message).to_s
1200
1212
  @turn_final_text = text unless text.strip.empty?
1213
+ # An answer (stopReason "stop"), kept apart from the run outcome that
1214
+ # every turn_start resets (#92).
1215
+ @pi_last_answer = message["stopReason"] == "stop" && !text.strip.empty? ? text : nil
1201
1216
 
1202
1217
  reason = message["stopReason"].to_s
1203
1218
  @pi_last_assistant_stop_reason = reason unless reason.empty?
@@ -1205,6 +1220,49 @@ module Harnex
1205
1220
  @pi_last_assistant_error = error unless error.empty?
1206
1221
  end
1207
1222
 
1223
+ # #92: a message that reaches the worker after it has answered (a
1224
+ # follow-up from the lane's owner or from anyone else) starts a new
1225
+ # answer; keep the one it replaces so the turn's result still has it.
1226
+ def note_pi_user_message(message)
1227
+ return unless message.is_a?(Hash) && message["role"] == "user"
1228
+
1229
+ text = @pi_last_answer.to_s.strip
1230
+ @pi_last_answer = nil
1231
+ return if text.empty? || @turn_earlier_replies.last == text
1232
+
1233
+ @turn_earlier_replies = (@turn_earlier_replies + [text]).last(MAX_EARLIER_REPLIES)
1234
+ end
1235
+
1236
+ # #91: commits the worker made, told apart from HEAD moving under it
1237
+ # (an orchestrator committing in the same checkout): HEAD is read around
1238
+ # each command that runs git, and only commits that appear during one
1239
+ # count as the worker's.
1240
+ def note_pi_git_head(message)
1241
+ args = message["args"]
1242
+ command = args["command"] if args.is_a?(Hash)
1243
+ return unless command.is_a?(String) && command.match?(/\bgit\b/) && @git_start && !@git_start.empty?
1244
+
1245
+ head = observed_git_head
1246
+ @pi_git_heads[message["toolCallId"].to_s] = head if head
1247
+ end
1248
+
1249
+ def record_pi_worker_commits(message)
1250
+ before = @pi_git_heads.delete(message["toolCallId"].to_s)
1251
+ after = before && observed_git_head
1252
+ return if after.nil? || after == before
1253
+
1254
+ shas = Harnex.git_output(git_observation_root, "rev-list", "--max-count=100", "#{before}..#{after}").split
1255
+ emit_event("worker_commits", shas: shas) unless shas.empty?
1256
+ rescue StandardError
1257
+ nil
1258
+ end
1259
+
1260
+ def observed_git_head
1261
+ Harnex.git_output(git_observation_root, "rev-parse", "HEAD")
1262
+ rescue StandardError
1263
+ nil
1264
+ end
1265
+
1208
1266
  def settle_pi_task!
1209
1267
  return if @stop_requested || @pi_transport_failed
1210
1268
 
@@ -1392,6 +1450,8 @@ module Harnex
1392
1450
  @completion_notification_sent = false
1393
1451
  @turn_follow_ups_sent = 0
1394
1452
  @turn_final_text = nil
1453
+ @turn_earlier_replies = []
1454
+ @pi_last_answer = nil
1395
1455
  @completion_turn += 1
1396
1456
  end
1397
1457
  return unless turn
@@ -1451,6 +1511,7 @@ module Harnex
1451
1511
  end
1452
1512
  fields = { final_message: text[0, FINAL_MESSAGE_LIMIT], final_message_path: path }
1453
1513
  fields[:final_message_truncated] = true if text.length > FINAL_MESSAGE_LIMIT
1514
+ fields[:earlier_replies] = @turn_earlier_replies.map { |reply| reply[0, FINAL_MESSAGE_LIMIT] } if @turn_earlier_replies.any?
1454
1515
  fields
1455
1516
  rescue StandardError
1456
1517
  { final_message: text[0, FINAL_MESSAGE_LIMIT] }
@@ -1658,11 +1719,13 @@ module Harnex
1658
1719
  warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
1659
1720
  end
1660
1721
 
1661
- def emit_send_event(text, force:)
1722
+ def emit_send_event(text, force:, from: nil)
1662
1723
  compact = text.to_s
1663
1724
  truncated = compact.length > 200
1664
1725
  preview = truncated ? "#{compact[0, 200]}…" : compact
1665
- emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
1726
+ payload = { msg: preview, msg_truncated: truncated, forced: !!force }
1727
+ payload[:from] = from if from.is_a?(Hash) && !from.empty?
1728
+ emit_event("send", **payload)
1666
1729
  end
1667
1730
 
1668
1731
  def emit_started_event
@@ -1685,7 +1748,7 @@ module Harnex
1685
1748
  )
1686
1749
  return if @git_start.empty?
1687
1750
 
1688
- payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch] }
1751
+ payload = { phase: "start", sha: @git_start[:sha], branch: @git_start[:branch], tracks_worker_commits: true }
1689
1752
  payload[:git_root] = git_observation_root if git_lane?
1690
1753
  emit_event("git", **payload)
1691
1754
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.29.0"
3
- RELEASE_DATE = "2026-10-22"
2
+ VERSION = "0.30.0"
3
+ RELEASE_DATE = "2026-10-25"
4
4
  end
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.29.0
4
+ version: 0.30.0
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-10-22 00:00:00.000000000 Z
11
+ date: 2026-10-25 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: 'Hand questions and tasks to Pi workers in visible tmux windows and get
14
14
  the result back in one call: lanes in git worktrees, follow-ups that keep context,