harnex 0.4.0 → 0.6.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.
@@ -5,10 +5,41 @@ require "pty"
5
5
  module Harnex
6
6
  class Session
7
7
  OUTPUT_BUFFER_LIMIT = 64 * 1024
8
+ TRANSCRIPT_TAIL_BYTES = 16 * 1024
9
+ USAGE_FIELDS = %i[
10
+ input_tokens output_tokens reasoning_tokens cached_tokens total_tokens agent_session_id
11
+ ].freeze
12
+ class EventCounters
13
+ def initialize
14
+ @counts = {
15
+ stalls: 0,
16
+ force_resumes: 0,
17
+ disconnections: 0,
18
+ compactions: 0
19
+ }
20
+ end
8
21
 
9
- attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path, :events_log_path
22
+ def record(type)
23
+ case type.to_s
24
+ when "log_idle"
25
+ @counts[:stalls] += 1
26
+ when "resume"
27
+ @counts[:force_resumes] += 1
28
+ when "disconnect", "disconnection", "disconnected"
29
+ @counts[:disconnections] += 1
30
+ when "compaction"
31
+ @counts[:compactions] += 1
32
+ end
33
+ end
10
34
 
11
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, inbox_ttl: Inbox::DEFAULT_TTL)
35
+ def snapshot
36
+ @counts.dup
37
+ end
38
+ end
39
+
40
+ attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path
41
+
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)
12
43
  @adapter = adapter
13
44
  @command = command
14
45
  @repo_root = repo_root
@@ -17,6 +48,9 @@ module Harnex
17
48
  @watch = watch
18
49
  @description = description.to_s.strip
19
50
  @description = nil if @description.empty?
51
+ @meta = meta
52
+ @summary_out = summary_out.to_s.strip
53
+ @summary_out = nil if @summary_out.empty?
20
54
  @registry_path = Harnex.registry_path(repo_root, @id)
21
55
  @output_log_path = Harnex.output_log_path(repo_root, @id)
22
56
  @events_log_path = Harnex.events_log_path(repo_root, @id)
@@ -34,6 +68,13 @@ module Harnex
34
68
  @output_log = nil
35
69
  @events_log = nil
36
70
  @events_log_seq = 0
71
+ @event_counters = EventCounters.new
72
+ @git_start = {}
73
+ @git_end = {}
74
+ @usage_summary = {}
75
+ @ended_at = nil
76
+ @exit_reason = nil
77
+ @last_completed_at = nil
37
78
  @writer = nil
38
79
  @pid = nil
39
80
  @term_signal = nil
@@ -65,9 +106,17 @@ module Harnex
65
106
  validate_binary! if validate_binary
66
107
  prepare_output_log
67
108
  prepare_events_log
109
+
110
+ return run_jsonrpc if adapter.transport == :stdio_jsonrpc
111
+
112
+ run_pty
113
+ end
114
+
115
+ def run_pty
68
116
  @reader, @writer, @pid = PTY.spawn(child_env, *command)
69
117
  @writer.sync = true
70
- emit_event("started", pid: @pid)
118
+ emit_started_event
119
+ emit_git_start_event
71
120
 
72
121
  install_signal_handlers
73
122
  sync_window_size
@@ -84,9 +133,15 @@ module Harnex
84
133
  _, status = Process.wait2(pid)
85
134
  @term_signal = status.signaled? ? status.termsig : nil
86
135
  @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
87
- emit_exit_event
136
+ @ended_at = Time.now
88
137
 
89
138
  output_thread.join(1)
139
+ emit_session_end_telemetry
140
+ @exit_reason = classify_exit
141
+ summary_record = build_summary_record
142
+ append_summary_record(summary_record)
143
+ emit_summary_event
144
+ emit_exit_event
90
145
  input_thread&.kill
91
146
  watch_thread&.kill
92
147
  @exit_code
@@ -132,6 +187,10 @@ module Harnex
132
187
  payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
133
188
  payload[:agent_state] = @state_machine.to_s
134
189
  payload[:inbox] = @inbox.stats
190
+ payload[:last_completed_at] = @last_completed_at&.iso8601
191
+ payload[:model] = meta_hash["model"]
192
+ payload[:effort] = meta_hash["effort"]
193
+ payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
135
194
  payload
136
195
  end
137
196
 
@@ -146,6 +205,18 @@ module Harnex
146
205
  end
147
206
 
148
207
  def inject_stop
208
+ if adapter.transport == :stdio_jsonrpc
209
+ @inject_mutex.synchronize do
210
+ begin
211
+ adapter.interrupt
212
+ rescue StandardError
213
+ nil
214
+ end
215
+ @state_machine.force_busy!
216
+ end
217
+ return { ok: true, signal: "interrupt_sent" }
218
+ end
219
+
149
220
  raise "session is not running" unless pid && Harnex.alive_pid?(pid)
150
221
 
151
222
  @inject_mutex.synchronize do
@@ -157,6 +228,10 @@ module Harnex
157
228
  end
158
229
 
159
230
  def inject_via_adapter(text:, submit:, enter_only:, force: false)
231
+ if adapter.transport == :stdio_jsonrpc
232
+ return inject_via_jsonrpc(text: text, force: force)
233
+ end
234
+
160
235
  snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
161
236
  payload = adapter.build_send_payload(
162
237
  text: text,
@@ -181,8 +256,32 @@ module Harnex
181
256
  .tap { emit_send_event(text, force: payload[:force]) }
182
257
  end
183
258
 
259
+ def inject_via_jsonrpc(text:, force: false)
260
+ turn_id = nil
261
+ @inject_mutex.synchronize do
262
+ turn_id = adapter.dispatch(prompt: text)
263
+ @state_machine.force_busy!
264
+ @injected_count += 1
265
+ @last_injected_at = Time.now
266
+ persist_registry
267
+ end
268
+
269
+ emit_send_event(text, force: force)
270
+ {
271
+ ok: true,
272
+ cli: adapter.key,
273
+ bytes_written: text.to_s.bytesize,
274
+ injected_count: @injected_count,
275
+ newline: false,
276
+ input_state: adapter.input_state(nil),
277
+ force: force,
278
+ turn_id: turn_id
279
+ }
280
+ end
281
+
184
282
  def sync_window_size
185
283
  return unless STDIN.tty?
284
+ return unless @writer
186
285
 
187
286
  @writer.winsize = STDIN.winsize
188
287
  rescue StandardError
@@ -195,6 +294,154 @@ module Harnex
195
294
 
196
295
  private
197
296
 
297
+ def run_jsonrpc
298
+ adapter.on_notification { |msg| handle_rpc_notification(msg) }
299
+ adapter.on_disconnect { |err| handle_rpc_disconnect(err) }
300
+
301
+ adapter.start_rpc(env: child_env, cwd: repo_root)
302
+ @pid = adapter.pid
303
+ emit_started_event
304
+ emit_git_start_event
305
+
306
+ install_signal_handlers
307
+ @server = ApiServer.new(self)
308
+ @server.start
309
+ persist_registry
310
+
311
+ watch_thread = start_watch_thread
312
+ @inbox.start
313
+
314
+ if @pid
315
+ begin
316
+ _, status = Process.wait2(@pid)
317
+ @term_signal = status.signaled? ? status.termsig : nil
318
+ @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
319
+ rescue Errno::ECHILD
320
+ @exit_code = 0
321
+ end
322
+ else
323
+ @rpc_done_lock = Mutex.new
324
+ @rpc_done_cond = ConditionVariable.new
325
+ @rpc_done_lock.synchronize { @rpc_done_cond.wait(@rpc_done_lock) until @rpc_done }
326
+ @exit_code = 0
327
+ end
328
+ @ended_at = Time.now
329
+
330
+ emit_session_end_telemetry
331
+ @exit_reason = classify_exit
332
+ summary_record = build_summary_record
333
+ append_summary_record(summary_record)
334
+ emit_summary_event
335
+ emit_exit_event
336
+ watch_thread&.kill
337
+ @exit_code
338
+ ensure
339
+ @inbox.stop
340
+ @server&.stop
341
+ begin
342
+ adapter.close
343
+ rescue StandardError
344
+ nil
345
+ end
346
+ persist_exit_status
347
+ cleanup_registry
348
+ @output_log&.close unless @output_log&.closed?
349
+ @events_log&.close unless @events_log&.closed?
350
+ end
351
+
352
+ def signal_rpc_done!
353
+ @rpc_done = true
354
+ if defined?(@rpc_done_lock) && @rpc_done_lock
355
+ @rpc_done_lock.synchronize { @rpc_done_cond&.signal }
356
+ end
357
+ end
358
+
359
+ def handle_rpc_notification(message)
360
+ method = message["method"]
361
+ params = message["params"] || {}
362
+
363
+ case method
364
+ when "thread/started"
365
+ @rpc_thread_id = params["threadId"] || params["thread_id"]
366
+ when "turn/started"
367
+ emit_event("turn_started", turnId: params["turnId"] || params["turn_id"])
368
+ when "turn/completed"
369
+ @last_completed_at = Time.now
370
+ payload = { turnId: params["turnId"] || params["turn_id"] }
371
+ payload[:status] = params["status"] if params["status"]
372
+ payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
373
+ emit_event("task_complete", **payload)
374
+ when "item/completed"
375
+ emit_event("item_completed", item: params["item"])
376
+ text = render_item_text(params["item"])
377
+ record_synthesized(text) if text
378
+ when "thread/compacted"
379
+ emit_event("compaction", **params)
380
+ when "thread/tokenUsage/updated"
381
+ # Surfaced via status fields in Phase 4; no event spam.
382
+ @token_usage = params["usage"] || params
383
+ when "thread/status/changed"
384
+ # State machine reflects RPC state; no event needed.
385
+ nil
386
+ when "account/rateLimits/updated"
387
+ @rate_limits = params
388
+ when "error"
389
+ emit_event("disconnected", source: "error_notification", message: params["message"])
390
+ signal_rpc_done!
391
+ end
392
+ rescue StandardError => e
393
+ warn("harnex: rpc notification handler error: #{e.message}")
394
+ end
395
+
396
+ def handle_rpc_disconnect(error)
397
+ msg = error.is_a?(Hash) ? error["message"] : error&.message
398
+ emit_event("disconnected", source: "transport", message: msg) rescue nil
399
+ signal_rpc_done!
400
+ end
401
+
402
+ def render_item_text(item)
403
+ return nil unless item.is_a?(Hash)
404
+
405
+ type = item["type"] || item["kind"]
406
+ case type
407
+ when "agent_message", "assistant_message"
408
+ item["text"] || item.dig("message", "text")
409
+ when "tool_call"
410
+ name = item["name"] || item.dig("tool", "name") || "tool"
411
+ params = item["params"] || item["arguments"]
412
+ "tool: #{name}#{params ? " #{summarize(params)}" : ""}"
413
+ else
414
+ item["text"]
415
+ end
416
+ end
417
+
418
+ def summarize(value)
419
+ str = value.is_a?(String) ? value : JSON.generate(value)
420
+ str.length > 120 ? "#{str[0, 117]}..." : str
421
+ rescue StandardError
422
+ ""
423
+ end
424
+
425
+ def record_synthesized(text)
426
+ return if text.nil? || text.to_s.empty?
427
+
428
+ payload = text.to_s.dup
429
+ payload << "\n" unless payload.end_with?("\n")
430
+ bytes = payload.b
431
+ @mutex.synchronize do
432
+ append_output_log(bytes)
433
+ @output_buffer << bytes
434
+ overflow = @output_buffer.bytesize - OUTPUT_BUFFER_LIMIT
435
+ @output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
436
+ end
437
+ begin
438
+ STDOUT.write(payload)
439
+ STDOUT.flush
440
+ rescue StandardError
441
+ nil
442
+ end
443
+ end
444
+
198
445
  def child_env
199
446
  env = {
200
447
  "HARNEX_SESSION_ID" => session_id,
@@ -390,13 +637,170 @@ module Harnex
390
637
  emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
391
638
  end
392
639
 
640
+ def emit_started_event
641
+ payload = { pid: @pid }
642
+ payload[:meta] = meta if meta
643
+ emit_event("started", **payload)
644
+ end
645
+
646
+ def emit_git_start_event
647
+ @git_start = Harnex.git_capture_start(repo_root)
648
+ return if @git_start.empty?
649
+
650
+ emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
651
+ end
652
+
653
+ def emit_session_end_telemetry
654
+ @usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
655
+ emit_event("usage", **@usage_summary)
656
+
657
+ @git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
658
+ return if @git_end.empty?
659
+
660
+ emit_event(
661
+ "git",
662
+ phase: "end",
663
+ sha: @git_end[:sha],
664
+ loc_added: @git_end[:loc_added],
665
+ loc_removed: @git_end[:loc_removed],
666
+ files_changed: @git_end[:files_changed],
667
+ commits: @git_end[:commits]
668
+ )
669
+ end
670
+
671
+ def emit_summary_event
672
+ emit_event("summary", path: summary_out, exit: @exit_reason)
673
+ end
674
+
393
675
  def emit_exit_event
394
676
  payload = { code: @exit_code }
395
677
  payload[:signal] = @term_signal if @term_signal
678
+ payload[:reason] = @exit_reason if @exit_reason
396
679
  emit_event("exited", **payload)
397
680
  end
398
681
 
682
+ def classify_exit
683
+ return "timeout" if @exit_code == 124
684
+ return "failure" unless @exit_code == 0
685
+ return "success" if session_summary_present?
686
+
687
+ "disconnected"
688
+ end
689
+
690
+ def session_summary_present?
691
+ @usage_summary.values.any? { |value| !value.nil? }
692
+ end
693
+
694
+ def build_summary_record
695
+ {
696
+ meta: build_summary_meta,
697
+ predicted: summary_predicted_payload,
698
+ actual: build_summary_actual
699
+ }
700
+ end
701
+
702
+ def build_summary_meta
703
+ info = Harnex.host_info
704
+ passthrough = meta_hash
705
+
706
+ {
707
+ id: id,
708
+ tmux_session: id,
709
+ description: description,
710
+ started_at: @started_at.iso8601,
711
+ ended_at: @ended_at&.iso8601,
712
+ harness: "harnex",
713
+ harness_version: Harnex.harness_version,
714
+ agent: adapter.key,
715
+ agent_version: nil,
716
+ agent_provider: nil,
717
+ agent_deployment: nil,
718
+ host: info[:host],
719
+ platform: info[:platform],
720
+ orchestrator: passthrough["orchestrator"],
721
+ orchestrator_session: passthrough["orchestrator_session"],
722
+ chain_id: passthrough["chain_id"],
723
+ parent_dispatch_id: passthrough["parent_dispatch_id"],
724
+ tier: passthrough["tier"],
725
+ phase: passthrough["phase"],
726
+ issue: passthrough["issue"],
727
+ plan: passthrough["plan"],
728
+ task_brief: passthrough["task_brief"],
729
+ repo: repo_root,
730
+ branch: @git_start[:branch],
731
+ start_sha: @git_start[:sha],
732
+ end_sha: @git_end[:sha]
733
+ }
734
+ end
735
+
736
+ def build_summary_actual
737
+ counters = @event_counters.snapshot
738
+ counters[:disconnections] = [counters[:disconnections], 1].max if @exit_reason == "disconnected"
739
+
740
+ {
741
+ model: meta_hash["model"],
742
+ effort: meta_hash["effort"],
743
+ duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
744
+ input_tokens: @usage_summary[:input_tokens],
745
+ output_tokens: @usage_summary[:output_tokens],
746
+ reasoning_tokens: @usage_summary[:reasoning_tokens],
747
+ cached_tokens: @usage_summary[:cached_tokens],
748
+ cost_usd: nil,
749
+ loc_added: @git_end[:loc_added],
750
+ loc_removed: @git_end[:loc_removed],
751
+ files_changed: @git_end[:files_changed],
752
+ commits: @git_end[:commits],
753
+ exit: @exit_reason,
754
+ stalls: counters[:stalls],
755
+ force_resumes: counters[:force_resumes],
756
+ disconnections: counters[:disconnections],
757
+ compactions: counters[:compactions],
758
+ tests_run: nil,
759
+ tests_passed: nil,
760
+ tests_failed: nil
761
+ }
762
+ end
763
+
764
+ def summary_predicted_payload
765
+ predicted = meta_hash["predicted"]
766
+ predicted.is_a?(Hash) ? predicted : {}
767
+ end
768
+
769
+ def meta_hash
770
+ meta.is_a?(Hash) ? meta : {}
771
+ end
772
+
773
+ def append_summary_record(record)
774
+ return unless summary_out
775
+
776
+ FileUtils.mkdir_p(File.dirname(summary_out))
777
+ File.open(summary_out, "ab") do |file|
778
+ file.write(JSON.generate(record))
779
+ file.write("\n")
780
+ end
781
+ rescue StandardError => e
782
+ warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
783
+ end
784
+
785
+ def normalized_usage_summary(summary)
786
+ summary ||= {}
787
+ USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
788
+ end
789
+
790
+ def transcript_tail
791
+ return "" unless File.file?(output_log_path)
792
+
793
+ File.open(output_log_path, "rb") do |file|
794
+ size = file.size
795
+ file.seek([size - TRANSCRIPT_TAIL_BYTES, 0].max)
796
+ Harnex.strip_ansi(file.read.to_s)
797
+ end
798
+ rescue StandardError
799
+ ""
800
+ end
801
+
399
802
  def emit_event(type, **payload)
803
+ @event_counters.record(type)
400
804
  @events_mutex.synchronize do
401
805
  return unless @events_log
402
806
 
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.4.0"
3
- RELEASE_DATE = "2026-04-30"
2
+ VERSION = "0.6.0"
3
+ RELEASE_DATE = "2026-05-06"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -25,4 +25,5 @@ require_relative "harnex/commands/pane"
25
25
  require_relative "harnex/commands/recipes"
26
26
  require_relative "harnex/commands/guide"
27
27
  require_relative "harnex/commands/skills"
28
+ require_relative "harnex/commands/doctor"
28
29
  require_relative "harnex/cli"
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.4.0
4
+ version: 0.6.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-04-29 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
14
14
  adds a control plane for discovery, messaging, and coordination.
@@ -19,6 +19,7 @@ executables:
19
19
  extensions: []
20
20
  extra_rdoc_files: []
21
21
  files:
22
+ - CHANGELOG.md
22
23
  - GUIDE.md
23
24
  - LICENSE
24
25
  - README.md
@@ -30,8 +31,10 @@ files:
30
31
  - lib/harnex/adapters/base.rb
31
32
  - lib/harnex/adapters/claude.rb
32
33
  - lib/harnex/adapters/codex.rb
34
+ - lib/harnex/adapters/codex_appserver.rb
33
35
  - lib/harnex/adapters/generic.rb
34
36
  - lib/harnex/cli.rb
37
+ - lib/harnex/commands/doctor.rb
35
38
  - lib/harnex/commands/events.rb
36
39
  - lib/harnex/commands/guide.rb
37
40
  - lib/harnex/commands/logs.rb