harnex 0.7.14 → 0.9.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.
@@ -23,7 +23,7 @@ module Harnex
23
23
  terminal_tokens terminal_percent peak_tokens peak_percent
24
24
  ].freeze
25
25
  CONTEXT_SAMPLE_STATUSES = %w[observed estimated missing].freeze
26
- ATTEMPT_KINDS = %w[initial retry fix review superseding].freeze
26
+ ATTEMPT_KINDS = %w[initial retry fix review fallback superseding].freeze
27
27
  SESSION_SUMMARY_SIGNAL_FIELDS = %i[
28
28
  input_tokens output_tokens reasoning_tokens cached_tokens total_tokens
29
29
  agent_session_id cost_usd
@@ -183,6 +183,7 @@ module Harnex
183
183
 
184
184
  def run(validate_binary: true)
185
185
  validate_binary! if validate_binary
186
+ prune_retained_logs
186
187
  prepare_output_log
187
188
  prepare_events_log
188
189
 
@@ -205,6 +206,7 @@ module Harnex
205
206
  @server = ApiServer.new(self)
206
207
  @server.start
207
208
  persist_registry
209
+ append_dispatch_start_record
208
210
 
209
211
  stdin_state = STDIN.tty? ? STDIN.raw! : nil
210
212
  watch_thread = start_watch_thread
@@ -474,6 +476,7 @@ module Harnex
474
476
  @server = ApiServer.new(self)
475
477
  @server.start
476
478
  persist_registry
479
+ append_dispatch_start_record
477
480
 
478
481
  watch_thread = start_watch_thread
479
482
  @inbox.start
@@ -1152,6 +1155,13 @@ module Harnex
1152
1155
  @output_log_failed = false
1153
1156
  end
1154
1157
 
1158
+ def prune_retained_logs
1159
+ Harnex::Retention.auto_prune(
1160
+ repo_root: repo_root,
1161
+ current_paths: [output_log_path, events_log_path]
1162
+ )
1163
+ end
1164
+
1155
1165
  def prepare_events_log
1156
1166
  @events_log&.close unless @events_log&.closed?
1157
1167
  @events_log = File.open(events_log_path, "ab")
@@ -1245,7 +1255,9 @@ module Harnex
1245
1255
  end
1246
1256
 
1247
1257
  def emit_summary_event
1248
- emit_event("summary", path: summary_out, exit: @exit_reason)
1258
+ payload = { path: dispatch_history_path, exit: @exit_reason }
1259
+ payload[:mirror_path] = summary_out if summary_out
1260
+ emit_event("summary", **payload)
1249
1261
  end
1250
1262
 
1251
1263
  def emit_attempt_finished(attempt)
@@ -1282,9 +1294,9 @@ module Harnex
1282
1294
  warn("harnex: failed to collect session-end telemetry: #{e.message}")
1283
1295
  end
1284
1296
  @exit_reason ||= classify_exit
1285
- record = build_summary_record
1297
+ record = DispatchHistory.build_record(self)
1298
+ append_dispatch_history_record(record)
1286
1299
  append_summary_record(record)
1287
- append_dispatch_history_record
1288
1300
  emit_summary_event
1289
1301
  emit_attempt_finished(record.fetch(:attempt))
1290
1302
  emit_exit_event
@@ -1579,10 +1591,30 @@ module Harnex
1579
1591
  cost_source ||= "provider_reported" if !@usage_summary[:cost_usd].nil?
1580
1592
  cost_source ||= summary_string(declared["cost_source"]) || "caller_estimate" if status == "estimated"
1581
1593
 
1594
+ cost_price_as_of = nil
1595
+ if values[:cost_usd].nil? && %w[observed zero].include?(status)
1596
+ priced = Pricing.compute(
1597
+ provider: summary_agent_provider,
1598
+ model: summary_model,
1599
+ input_tokens: values[:input_tokens],
1600
+ output_tokens: values[:output_tokens],
1601
+ cached_tokens: values[:cached_tokens],
1602
+ service_tier: summary_service_tier,
1603
+ context_tokens: @context_summary[:peak_tokens],
1604
+ input_includes_cached: adapter.usage_input_includes_cached?
1605
+ )
1606
+ if priced
1607
+ values[:cost_usd] = priced[:cost_usd]
1608
+ cost_source = "price_table"
1609
+ cost_price_as_of = priced[:as_of]
1610
+ end
1611
+ end
1612
+
1582
1613
  {
1583
1614
  "status" => status,
1584
1615
  "cost_usd" => values[:cost_usd],
1585
1616
  "cost_source" => cost_source,
1617
+ "cost_price_as_of" => cost_price_as_of,
1586
1618
  "input_tokens" => values[:input_tokens],
1587
1619
  "output_tokens" => values[:output_tokens],
1588
1620
  "cached_input_tokens" => values[:cached_tokens],
@@ -1760,6 +1792,101 @@ module Harnex
1760
1792
  @exit_reason == "success"
1761
1793
  end
1762
1794
 
1795
+ def attempt_chain_summary
1796
+ @attempt_chain_summary ||= begin
1797
+ current = {
1798
+ id: id,
1799
+ parent_dispatch_id: summary_string(meta_hash["parent_dispatch_id"]) || @parent_harnex_id,
1800
+ kind: summary_attempt_kind,
1801
+ outcome: summary_attempt_succeeded? ? "succeeded" : "failed"
1802
+ }
1803
+ index = historical_attempt_index
1804
+ nodes = [current]
1805
+ seen = {}
1806
+ seen[current[:id]] = true if current[:id]
1807
+ parent = current[:parent_dispatch_id]
1808
+ immediate_parent = nil
1809
+
1810
+ while parent && !seen[parent]
1811
+ record = index[parent]
1812
+ break unless record
1813
+
1814
+ node = historical_attempt_node(record)
1815
+ break unless node
1816
+
1817
+ immediate_parent ||= node
1818
+ nodes << node
1819
+ seen[node[:id]] = true if node[:id]
1820
+ parent = node[:parent_dispatch_id]
1821
+ end
1822
+
1823
+ {
1824
+ attempts_total: nodes.length,
1825
+ attempts_succeeded: nodes.count { |node| node[:outcome] == "succeeded" },
1826
+ attempts_failed: nodes.count { |node| node[:outcome] == "failed" },
1827
+ fallback_triggered: nodes.any? { |node| node[:kind] == "fallback" },
1828
+ recovered: current[:outcome] == "succeeded" && immediate_parent&.dig(:outcome) == "failed"
1829
+ }
1830
+ end
1831
+ end
1832
+
1833
+ def historical_attempt_index
1834
+ path = dispatch_history_path
1835
+ return {} unless File.file?(path)
1836
+
1837
+ records = {}
1838
+ duplicates = {}
1839
+ File.foreach(path) do |line|
1840
+ record = JSON.parse(line)
1841
+ next unless DispatchHistory.end_record?(record)
1842
+
1843
+ dispatch_id = summary_string(record["id"])
1844
+ next unless dispatch_id
1845
+
1846
+ if records.key?(dispatch_id)
1847
+ records.delete(dispatch_id)
1848
+ duplicates[dispatch_id] = true
1849
+ elsif !duplicates.key?(dispatch_id)
1850
+ records[dispatch_id] = record
1851
+ end
1852
+ rescue JSON::ParserError
1853
+ next
1854
+ end
1855
+ records
1856
+ rescue StandardError
1857
+ {}
1858
+ end
1859
+
1860
+ def historical_attempt_node(record)
1861
+ attempt = record["attempt"].is_a?(Hash) ? record["attempt"] : {}
1862
+ meta = record["meta"].is_a?(Hash) ? record["meta"] : {}
1863
+ dispatch_id = summary_string(record["id"])
1864
+ return nil unless dispatch_id
1865
+
1866
+ {
1867
+ id: dispatch_id,
1868
+ parent_dispatch_id: summary_string(attempt["parent_dispatch_id"]) || summary_string(meta["parent_dispatch_id"]),
1869
+ kind: summary_string(attempt["kind"]),
1870
+ outcome: historical_attempt_outcome(record, attempt)
1871
+ }
1872
+ end
1873
+
1874
+ def historical_attempt_outcome(record, attempt)
1875
+ case summary_string(attempt["status"])
1876
+ when "succeeded"
1877
+ "succeeded"
1878
+ when "failed"
1879
+ "failed"
1880
+ else
1881
+ case summary_string(record["status"])
1882
+ when "completed"
1883
+ "succeeded"
1884
+ when "failed", "timeout", "killed"
1885
+ "failed"
1886
+ end
1887
+ end
1888
+ end
1889
+
1763
1890
  def accepted_throughput_tokens_per_s(total_tokens, duration_s, accepted)
1764
1891
  return nil unless accepted && total_tokens.is_a?(Numeric) && duration_s.to_f.positive?
1765
1892
 
@@ -1783,6 +1910,7 @@ module Harnex
1783
1910
  def build_summary_reliability
1784
1911
  counters = reliability_event_counters
1785
1912
  real_disconnections = counters[:disconnections].to_i
1913
+ chain = attempt_chain_summary
1786
1914
  {
1787
1915
  "adapter_close" => summary_adapter_close(real_disconnections),
1788
1916
  "real_disconnections" => real_disconnections,
@@ -1790,7 +1918,7 @@ module Harnex
1790
1918
  "stalls" => counters[:stalls].to_i,
1791
1919
  "force_resumes" => counters[:force_resumes].to_i,
1792
1920
  "compactions" => counters[:compactions].to_i,
1793
- "recovered" => false
1921
+ "recovered" => chain[:recovered]
1794
1922
  }
1795
1923
  end
1796
1924
 
@@ -1799,7 +1927,7 @@ module Harnex
1799
1927
  output_measurements = summary_output_measurements
1800
1928
  accepted = outcome["status"] == "accepted"
1801
1929
  duration_s = @ended_at ? (@ended_at - @started_at).to_i : nil
1802
- attempt_succeeded = summary_attempt_succeeded?
1930
+ chain = attempt_chain_summary
1803
1931
  total_tokens = @usage_summary[:total_tokens]
1804
1932
 
1805
1933
  actual = {
@@ -1837,9 +1965,9 @@ module Harnex
1837
1965
  event_records: @events_log_seq,
1838
1966
  output_log_path: output_log_path,
1839
1967
  events_log_path: events_log_path,
1840
- attempts_total: 1,
1841
- attempts_succeeded: attempt_succeeded ? 1 : 0,
1842
- attempts_failed: attempt_succeeded ? 0 : 1,
1968
+ attempts_total: chain[:attempts_total],
1969
+ attempts_succeeded: chain[:attempts_succeeded],
1970
+ attempts_failed: chain[:attempts_failed],
1843
1971
  retry_count: counters[:retries],
1844
1972
  throttle_429_count: counters[:throttle_429],
1845
1973
  disconnect_count: counters[:disconnections],
@@ -1847,7 +1975,7 @@ module Harnex
1847
1975
  throughput_successes_per_h: accepted_throughput_successes_per_h(duration_s, accepted),
1848
1976
  retry_tax_pct: counters[:retries].to_i.zero? ? 0.0 : nil,
1849
1977
  unattributed: attribution["status"] != "complete",
1850
- fallback_triggered: false
1978
+ fallback_triggered: chain[:fallback_triggered]
1851
1979
  }
1852
1980
  actual
1853
1981
  end
@@ -1981,6 +2109,8 @@ module Harnex
1981
2109
  meta.is_a?(Hash) ? meta : {}
1982
2110
  end
1983
2111
 
2112
+ # Explicit-only mirror: the identical end record lands here in addition
2113
+ # to the tracked stream when --summary-out is configured.
1984
2114
  def append_summary_record(record)
1985
2115
  return unless summary_out
1986
2116
 
@@ -1993,13 +2123,26 @@ module Harnex
1993
2123
  warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
1994
2124
  end
1995
2125
 
1996
- def append_dispatch_history_record
1997
- path = DispatchHistory.path_for(launch_cwd)
1998
- DispatchHistory.append(path, DispatchHistory.build_record(self))
2126
+ def append_dispatch_start_record
2127
+ DispatchHistory.append(dispatch_history_path, DispatchHistory.build_start_record(self))
2128
+ rescue StandardError => e
2129
+ warn("harnex: failed to write dispatch start record: #{e.message}")
2130
+ end
2131
+
2132
+ def append_dispatch_history_record(record)
2133
+ DispatchHistory.append(dispatch_history_path, record)
1999
2134
  rescue StandardError => e
2000
2135
  warn("harnex: failed to write dispatch history: #{e.message}")
2001
2136
  end
2002
2137
 
2138
+ # One canonical stream per session: the start row and the end row must
2139
+ # land in the same file so readers can pair them. repo_root — not the
2140
+ # launch cwd — is the root that registry, events, and default summary
2141
+ # paths already key off.
2142
+ def dispatch_history_path
2143
+ @dispatch_history_path ||= DispatchHistory.path_for(repo_root)
2144
+ end
2145
+
2003
2146
  def normalized_usage_summary(summary)
2004
2147
  summary ||= {}
2005
2148
  USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
@@ -78,8 +78,18 @@ module Harnex
78
78
  File.foreach(path) do |line|
79
79
  record = JSON.parse(line)
80
80
  next unless record.is_a?(Hash)
81
+ next if DispatchHistory.start_record?(record)
81
82
 
82
- if summary_record?(record) && record.dig("meta", "id").to_s == id
83
+ # Branch on record_type first: a v2 end row carries both the thin
84
+ # envelope and the rich sections, so it resolves as summary and
85
+ # history in one shot — never double-counted via the legacy
86
+ # duck-types below, which stay for pre-v2 files.
87
+ if record["record_type"] == "dispatch_end"
88
+ next unless record["id"].to_s == id
89
+
90
+ history_record = record
91
+ summary_record = record if summary_record?(record)
92
+ elsif summary_record?(record) && record.dig("meta", "id").to_s == id
83
93
  summary_record = record
84
94
  elsif history_record?(record) && record["id"].to_s == id
85
95
  history_record = record
@@ -97,6 +107,7 @@ module Harnex
97
107
  record["meta"].is_a?(Hash) && record["actual"].is_a?(Hash)
98
108
  end
99
109
 
110
+ # Legacy thin end rows predate record_type.
100
111
  def history_record?(record)
101
112
  record["schema_version"] == 1 && record.key?("status")
102
113
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.14"
3
- RELEASE_DATE = "2026-07-15"
2
+ VERSION = "0.9.0"
3
+ RELEASE_DATE = "2026-08-03"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -5,6 +5,9 @@ require "open3"
5
5
  require_relative "harnex/version"
6
6
  require_relative "harnex/core"
7
7
  require_relative "harnex/dispatch_history"
8
+ require_relative "harnex/config"
9
+ require_relative "harnex/retention"
10
+ require_relative "harnex/pricing"
8
11
  require_relative "harnex/terminal_status"
9
12
  require_relative "harnex/artifact_report"
10
13
  require_relative "harnex/context_telemetry"
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.7.14
4
+ version: 0.9.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-07-15 00:00:00.000000000 Z
11
+ date: 2026-08-02 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex, Pi)
14
14
  and adds a control plane for discovery, messaging, and coordination.
@@ -26,6 +26,10 @@ files:
26
26
  - TECHNICAL.md
27
27
  - bin/gem-push
28
28
  - bin/harnex
29
+ - docs/codex-appserver.md
30
+ - docs/configuration.md
31
+ - docs/dispatch-telemetry.md
32
+ - docs/events.md
29
33
  - guides/01_dispatch.md
30
34
  - guides/02_chain.md
31
35
  - guides/03_buddy.md
@@ -60,10 +64,13 @@ files:
60
64
  - lib/harnex/commands/wait.rb
61
65
  - lib/harnex/commands/watch.rb
62
66
  - lib/harnex/commands/watch_presets.rb
67
+ - lib/harnex/config.rb
63
68
  - lib/harnex/context_telemetry.rb
64
69
  - lib/harnex/core.rb
65
70
  - lib/harnex/dispatch_history.rb
66
71
  - lib/harnex/orchestration.rb
72
+ - lib/harnex/pricing.rb
73
+ - lib/harnex/retention.rb
67
74
  - lib/harnex/runtime/api_server.rb
68
75
  - lib/harnex/runtime/file_change_hook.rb
69
76
  - lib/harnex/runtime/inbox.rb