harnex 0.3.4 → 0.5.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.
data/lib/harnex/core.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "digest"
2
2
  require "fileutils"
3
+ require "optparse"
3
4
  require "securerandom"
4
5
  require "set"
5
6
  require "socket"
@@ -37,6 +38,93 @@ module Harnex
37
38
  File.expand_path(path)
38
39
  end
39
40
 
41
+ def parse_duration_seconds(value, option_name:)
42
+ text = value.to_s.strip
43
+ raise OptionParser::InvalidArgument, "#{option_name} requires a value" if text.empty?
44
+
45
+ match = text.match(/\A([0-9]+(?:\.[0-9]+)?)([smhSMH]?)\z/)
46
+ unless match
47
+ raise OptionParser::InvalidArgument,
48
+ "#{option_name} must be a positive duration (examples: 30, 30s, 5m, 2h)"
49
+ end
50
+
51
+ amount = Float(match[1])
52
+ multiplier =
53
+ case match[2].downcase
54
+ when "", "s" then 1.0
55
+ when "m" then 60.0
56
+ when "h" then 3600.0
57
+ else
58
+ raise OptionParser::InvalidArgument, "#{option_name} has an unsupported duration suffix"
59
+ end
60
+
61
+ seconds = amount * multiplier
62
+ raise OptionParser::InvalidArgument, "#{option_name} must be greater than 0" if seconds <= 0.0
63
+
64
+ seconds
65
+ end
66
+
67
+ def harness_version
68
+ VERSION
69
+ end
70
+
71
+ def host_info
72
+ {
73
+ host: Socket.gethostname,
74
+ platform: RUBY_PLATFORM
75
+ }
76
+ rescue StandardError
77
+ {
78
+ host: nil,
79
+ platform: RUBY_PLATFORM
80
+ }
81
+ end
82
+
83
+ def strip_ansi(text)
84
+ text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
85
+ end
86
+
87
+ def default_summary_out_path(repo_root)
88
+ koder_dir = File.join(repo_root.to_s, "koder")
89
+ return nil unless File.directory?(koder_dir)
90
+
91
+ File.join(koder_dir, "DISPATCH.jsonl")
92
+ end
93
+
94
+ def git_capture_start(repo_root)
95
+ sha = git_output(repo_root, "rev-parse", "HEAD")
96
+ branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
97
+ return {} if sha.empty? || branch.empty?
98
+
99
+ {
100
+ sha: sha,
101
+ branch: branch
102
+ }
103
+ rescue StandardError
104
+ {}
105
+ end
106
+
107
+ def git_capture_end(repo_root, start_sha)
108
+ start_sha = start_sha.to_s.strip
109
+ return {} if start_sha.empty?
110
+
111
+ end_sha = git_output(repo_root, "rev-parse", "HEAD")
112
+ range = "#{start_sha}..#{end_sha}"
113
+ shortstat = git_output(repo_root, "diff", "--shortstat", range)
114
+ commits = Integer(git_output(repo_root, "rev-list", "--count", range))
115
+ stats = parse_git_shortstat(shortstat)
116
+
117
+ {
118
+ sha: end_sha,
119
+ loc_added: stats.fetch(:loc_added),
120
+ loc_removed: stats.fetch(:loc_removed),
121
+ files_changed: stats.fetch(:files_changed),
122
+ commits: commits
123
+ }
124
+ rescue StandardError
125
+ {}
126
+ end
127
+
40
128
  def repo_key(repo_root)
41
129
  Digest::SHA256.hexdigest(repo_root)[0, 16]
42
130
  end
@@ -113,6 +201,12 @@ module Harnex
113
201
  File.join(output_dir, "#{session_file_slug(repo_root, id)}.log")
114
202
  end
115
203
 
204
+ def events_log_path(repo_root, id)
205
+ events_dir = File.join(STATE_DIR, "events")
206
+ FileUtils.mkdir_p(events_dir)
207
+ File.join(events_dir, "#{session_file_slug(repo_root, id)}.jsonl")
208
+ end
209
+
116
210
  def session_file_slug(repo_root, id)
117
211
  slug = id_key(id)
118
212
  slug = "default" if slug.empty?
@@ -283,4 +377,19 @@ module Harnex
283
377
  debounce_seconds: WATCH_DEBOUNCE_SECONDS
284
378
  )
285
379
  end
380
+
381
+ def git_output(repo_root, *args)
382
+ stdout, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args)
383
+ raise "git #{args.join(' ')} failed" unless status.success?
384
+
385
+ stdout.strip
386
+ end
387
+
388
+ def parse_git_shortstat(text)
389
+ {
390
+ files_changed: text.to_s[/(\d+)\s+files?\s+changed/, 1].to_i,
391
+ loc_added: text.to_s[/(\d+)\s+insertions?\(\+\)/, 1].to_i,
392
+ loc_removed: text.to_s[/(\d+)\s+deletions?\(-\)/, 1].to_i
393
+ }
394
+ end
286
395
  end
@@ -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
21
+
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"
29
+ @counts[:disconnections] += 1
30
+ when "compaction"
31
+ @counts[:compactions] += 1
32
+ end
33
+ end
34
+
35
+ def snapshot
36
+ @counts.dup
37
+ end
38
+ end
8
39
 
9
- attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path
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
10
41
 
11
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, inbox_ttl: Inbox::DEFAULT_TTL)
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,19 +48,32 @@ 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)
56
+ @events_log_path = Harnex.events_log_path(repo_root, @id)
22
57
  @session_id = SecureRandom.hex(8)
23
58
  @token = SecureRandom.hex(16)
24
59
  @port = Harnex.allocate_port(repo_root, @id, port, host: host)
25
60
  @mutex = Mutex.new
26
61
  @inject_mutex = Mutex.new
62
+ @events_mutex = Mutex.new
27
63
  @injected_count = 0
28
64
  @last_injected_at = nil
29
65
  @started_at = Time.now
30
66
  @server = nil
31
67
  @reader = nil
32
68
  @output_log = nil
69
+ @events_log = nil
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
33
77
  @writer = nil
34
78
  @pid = nil
35
79
  @term_signal = nil
@@ -60,8 +104,11 @@ module Harnex
60
104
  def run(validate_binary: true)
61
105
  validate_binary! if validate_binary
62
106
  prepare_output_log
107
+ prepare_events_log
63
108
  @reader, @writer, @pid = PTY.spawn(child_env, *command)
64
109
  @writer.sync = true
110
+ emit_started_event
111
+ emit_git_start_event
65
112
 
66
113
  install_signal_handlers
67
114
  sync_window_size
@@ -78,8 +125,15 @@ module Harnex
78
125
  _, status = Process.wait2(pid)
79
126
  @term_signal = status.signaled? ? status.termsig : nil
80
127
  @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
128
+ @ended_at = Time.now
81
129
 
82
130
  output_thread.join(1)
131
+ emit_session_end_telemetry
132
+ @exit_reason = classify_exit
133
+ summary_record = build_summary_record
134
+ append_summary_record(summary_record)
135
+ emit_summary_event
136
+ emit_exit_event
83
137
  input_thread&.kill
84
138
  watch_thread&.kill
85
139
  @exit_code
@@ -91,6 +145,7 @@ module Harnex
91
145
  cleanup_registry
92
146
  @reader&.close unless @reader&.closed?
93
147
  @output_log&.close unless @output_log&.closed?
148
+ @events_log&.close unless @events_log&.closed?
94
149
  @writer&.close unless @writer&.closed?
95
150
  end
96
151
 
@@ -109,8 +164,10 @@ module Harnex
109
164
  started_at: @started_at.iso8601,
110
165
  last_injected_at: @last_injected_at&.iso8601,
111
166
  injected_count: @injected_count,
112
- output_log_path: output_log_path
167
+ output_log_path: output_log_path,
168
+ events_log_path: events_log_path
113
169
  }
170
+ payload.merge!(log_activity_snapshot)
114
171
  payload[:description] = description if description
115
172
 
116
173
  if watch
@@ -168,6 +225,7 @@ module Harnex
168
225
  input_state: payload[:input_state],
169
226
  force: payload[:force]
170
227
  )
228
+ .tap { emit_send_event(text, force: payload[:force]) }
171
229
  end
172
230
 
173
231
  def sync_window_size
@@ -327,6 +385,14 @@ module Harnex
327
385
  @output_log_failed = false
328
386
  end
329
387
 
388
+ def prepare_events_log
389
+ @events_log&.close unless @events_log&.closed?
390
+ @events_log = File.open(events_log_path, "ab")
391
+ @events_log.sync = true
392
+ @events_log_failed = false
393
+ @events_log_seq = 0
394
+ end
395
+
330
396
  def install_signal_handlers
331
397
  %w[INT TERM HUP QUIT].each do |signal_name|
332
398
  Signal.trap(signal_name) { forward_signal(signal_name) }
@@ -364,6 +430,217 @@ module Harnex
364
430
  warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
365
431
  end
366
432
 
433
+ def emit_send_event(text, force:)
434
+ compact = text.to_s
435
+ truncated = compact.length > 200
436
+ preview = truncated ? "#{compact[0, 200]}…" : compact
437
+ emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
438
+ end
439
+
440
+ def emit_started_event
441
+ payload = { pid: @pid }
442
+ payload[:meta] = meta if meta
443
+ emit_event("started", **payload)
444
+ end
445
+
446
+ def emit_git_start_event
447
+ @git_start = Harnex.git_capture_start(repo_root)
448
+ return if @git_start.empty?
449
+
450
+ emit_event("git", phase: "start", sha: @git_start[:sha], branch: @git_start[:branch])
451
+ end
452
+
453
+ def emit_session_end_telemetry
454
+ @usage_summary = normalized_usage_summary(adapter.parse_session_summary(transcript_tail))
455
+ emit_event("usage", **@usage_summary)
456
+
457
+ @git_end = Harnex.git_capture_end(repo_root, @git_start[:sha])
458
+ return if @git_end.empty?
459
+
460
+ emit_event(
461
+ "git",
462
+ phase: "end",
463
+ sha: @git_end[:sha],
464
+ loc_added: @git_end[:loc_added],
465
+ loc_removed: @git_end[:loc_removed],
466
+ files_changed: @git_end[:files_changed],
467
+ commits: @git_end[:commits]
468
+ )
469
+ end
470
+
471
+ def emit_summary_event
472
+ emit_event("summary", path: summary_out, exit: @exit_reason)
473
+ end
474
+
475
+ def emit_exit_event
476
+ payload = { code: @exit_code }
477
+ payload[:signal] = @term_signal if @term_signal
478
+ payload[:reason] = @exit_reason if @exit_reason
479
+ emit_event("exited", **payload)
480
+ end
481
+
482
+ def classify_exit
483
+ return "timeout" if @exit_code == 124
484
+ return "failure" unless @exit_code == 0
485
+ return "success" if session_summary_present?
486
+
487
+ "disconnected"
488
+ end
489
+
490
+ def session_summary_present?
491
+ @usage_summary.values.any? { |value| !value.nil? }
492
+ end
493
+
494
+ def build_summary_record
495
+ {
496
+ meta: build_summary_meta,
497
+ predicted: summary_predicted_payload,
498
+ actual: build_summary_actual
499
+ }
500
+ end
501
+
502
+ def build_summary_meta
503
+ info = Harnex.host_info
504
+ passthrough = meta_hash
505
+
506
+ {
507
+ id: id,
508
+ tmux_session: id,
509
+ description: description,
510
+ started_at: @started_at.iso8601,
511
+ ended_at: @ended_at&.iso8601,
512
+ harness: "harnex",
513
+ harness_version: Harnex.harness_version,
514
+ agent: adapter.key,
515
+ agent_version: nil,
516
+ agent_provider: nil,
517
+ agent_deployment: nil,
518
+ host: info[:host],
519
+ platform: info[:platform],
520
+ orchestrator: passthrough["orchestrator"],
521
+ orchestrator_session: passthrough["orchestrator_session"],
522
+ chain_id: passthrough["chain_id"],
523
+ parent_dispatch_id: passthrough["parent_dispatch_id"],
524
+ tier: passthrough["tier"],
525
+ phase: passthrough["phase"],
526
+ issue: passthrough["issue"],
527
+ plan: passthrough["plan"],
528
+ task_brief: passthrough["task_brief"],
529
+ repo: repo_root,
530
+ branch: @git_start[:branch],
531
+ start_sha: @git_start[:sha],
532
+ end_sha: @git_end[:sha]
533
+ }
534
+ end
535
+
536
+ def build_summary_actual
537
+ counters = @event_counters.snapshot
538
+ counters[:disconnections] = [counters[:disconnections], 1].max if @exit_reason == "disconnected"
539
+
540
+ {
541
+ model: meta_hash["model"],
542
+ effort: meta_hash["effort"],
543
+ duration_s: @ended_at ? (@ended_at - @started_at).to_i : nil,
544
+ input_tokens: @usage_summary[:input_tokens],
545
+ output_tokens: @usage_summary[:output_tokens],
546
+ reasoning_tokens: @usage_summary[:reasoning_tokens],
547
+ cached_tokens: @usage_summary[:cached_tokens],
548
+ cost_usd: nil,
549
+ loc_added: @git_end[:loc_added],
550
+ loc_removed: @git_end[:loc_removed],
551
+ files_changed: @git_end[:files_changed],
552
+ commits: @git_end[:commits],
553
+ exit: @exit_reason,
554
+ stalls: counters[:stalls],
555
+ force_resumes: counters[:force_resumes],
556
+ disconnections: counters[:disconnections],
557
+ compactions: counters[:compactions],
558
+ tests_run: nil,
559
+ tests_passed: nil,
560
+ tests_failed: nil
561
+ }
562
+ end
563
+
564
+ def summary_predicted_payload
565
+ predicted = meta_hash["predicted"]
566
+ predicted.is_a?(Hash) ? predicted : {}
567
+ end
568
+
569
+ def meta_hash
570
+ meta.is_a?(Hash) ? meta : {}
571
+ end
572
+
573
+ def append_summary_record(record)
574
+ return unless summary_out
575
+
576
+ FileUtils.mkdir_p(File.dirname(summary_out))
577
+ File.open(summary_out, "ab") do |file|
578
+ file.write(JSON.generate(record))
579
+ file.write("\n")
580
+ end
581
+ rescue StandardError => e
582
+ warn("harnex: failed to write dispatch summary #{summary_out}: #{e.message}")
583
+ end
584
+
585
+ def normalized_usage_summary(summary)
586
+ summary ||= {}
587
+ USAGE_FIELDS.to_h { |field| [field, summary[field] || summary[field.to_s]] }
588
+ end
589
+
590
+ def transcript_tail
591
+ return "" unless File.file?(output_log_path)
592
+
593
+ File.open(output_log_path, "rb") do |file|
594
+ size = file.size
595
+ file.seek([size - TRANSCRIPT_TAIL_BYTES, 0].max)
596
+ Harnex.strip_ansi(file.read.to_s)
597
+ end
598
+ rescue StandardError
599
+ ""
600
+ end
601
+
602
+ def emit_event(type, **payload)
603
+ @event_counters.record(type)
604
+ @events_mutex.synchronize do
605
+ return unless @events_log
606
+
607
+ @events_log_seq += 1
608
+ event = {
609
+ schema_version: 1,
610
+ seq: @events_log_seq,
611
+ ts: Time.now.utc.iso8601,
612
+ id: id,
613
+ type: type
614
+ }.merge(payload)
615
+ @events_log.write(JSON.generate(event))
616
+ @events_log.write("\n")
617
+ @events_log.flush
618
+ end
619
+ rescue StandardError => e
620
+ return if defined?(@events_log_failed) && @events_log_failed
621
+
622
+ @events_log_failed = true
623
+ warn("harnex: failed to write events log #{events_log_path}: #{e.message}")
624
+ end
625
+
626
+ def log_activity_snapshot
627
+ return { log_mtime: nil, log_idle_s: nil } unless File.file?(output_log_path)
628
+ return { log_mtime: nil, log_idle_s: nil } if File.size?(output_log_path).nil?
629
+
630
+ mtime = File.mtime(output_log_path)
631
+ idle_seconds = (Time.now - mtime).to_i
632
+ idle_seconds = 0 if idle_seconds.negative?
633
+ {
634
+ log_mtime: mtime.iso8601,
635
+ log_idle_s: idle_seconds
636
+ }
637
+ rescue StandardError
638
+ {
639
+ log_mtime: nil,
640
+ log_idle_s: nil
641
+ }
642
+ end
643
+
367
644
  def screen_snapshot
368
645
  @mutex.synchronize { @output_buffer.dup }
369
646
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.3.4"
3
- RELEASE_DATE = "2026-04-24"
2
+ VERSION = "0.5.0"
3
+ RELEASE_DATE = "2026-05-01"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -12,12 +12,15 @@ require_relative "harnex/runtime/inbox"
12
12
  require_relative "harnex/runtime/file_change_hook"
13
13
  require_relative "harnex/runtime/api_server"
14
14
  require_relative "harnex/runtime/session"
15
+ require_relative "harnex/commands/watch"
16
+ require_relative "harnex/commands/watch_presets"
15
17
  require_relative "harnex/commands/run"
16
18
  require_relative "harnex/commands/send"
17
19
  require_relative "harnex/commands/wait"
18
20
  require_relative "harnex/commands/stop"
19
21
  require_relative "harnex/commands/status"
20
22
  require_relative "harnex/commands/logs"
23
+ require_relative "harnex/commands/events"
21
24
  require_relative "harnex/commands/pane"
22
25
  require_relative "harnex/commands/recipes"
23
26
  require_relative "harnex/commands/guide"
@@ -8,6 +8,11 @@ description: Spawn an accountability partner for long-running harnex sessions. U
8
8
  For any long-running or unattended work, spawn a **buddy** — a second harnex
9
9
  agent that watches the worker and nudges it if it stalls.
10
10
 
11
+ For plain stall recovery (force-resume on inactivity), prefer
12
+ `harnex run --watch --preset impl`. Use a buddy when you need reasoning that
13
+ policy checks cannot provide (doc drift, semantic checks, multi-session
14
+ correlation).
15
+
11
16
  The buddy is an LLM, so it has intelligence for free. It reads the worker's
12
17
  screen, reasons about whether it's stuck, and composes a meaningful nudge.
13
18
 
@@ -119,11 +119,27 @@ harnex run codex --id cx-impl-NN --tmux cx-impl-NN \
119
119
  --context "Read and execute /tmp/task-impl-NN.md"
120
120
  ```
121
121
 
122
+ ### Built-in monitoring (`--watch`)
123
+
124
+ For unattended implementation runs where you only need stall policy (not
125
+ Claude-side reasoning), bundle dispatch and monitoring in one command:
126
+
127
+ ```bash
128
+ harnex run codex --id cx-impl-42 --tmux cx-impl-42 --watch --preset impl
129
+ ```
130
+
131
+ `--preset impl` applies the standard 8m stall threshold with one forced resume.
132
+ Trade-off: `--watch` is foreground-blocking and policy-only (`stall-after` +
133
+ `max-resumes`). Use pane polling (and buddy when needed) for richer reasoning.
134
+
122
135
  ## 2. Watch
123
136
 
124
137
  Poll the agent's screen with `harnex pane`. Checking is cheap — a 20-line
125
138
  tail is a few hundred bytes.
126
139
 
140
+ For structured orchestration, prefer `harnex events --id <id>` over pane-text
141
+ scraping.
142
+
127
143
  **Default: poll every 30 seconds.** This is fine for most work. The check
128
144
  itself costs almost nothing and catches completion quickly.
129
145
 
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.3.4
4
+ version: 0.5.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-24 00:00:00.000000000 Z
11
+ date: 2026-05-01 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.
@@ -32,6 +32,7 @@ files:
32
32
  - lib/harnex/adapters/codex.rb
33
33
  - lib/harnex/adapters/generic.rb
34
34
  - lib/harnex/cli.rb
35
+ - lib/harnex/commands/events.rb
35
36
  - lib/harnex/commands/guide.rb
36
37
  - lib/harnex/commands/logs.rb
37
38
  - lib/harnex/commands/pane.rb
@@ -42,6 +43,8 @@ files:
42
43
  - lib/harnex/commands/status.rb
43
44
  - lib/harnex/commands/stop.rb
44
45
  - lib/harnex/commands/wait.rb
46
+ - lib/harnex/commands/watch.rb
47
+ - lib/harnex/commands/watch_presets.rb
45
48
  - lib/harnex/core.rb
46
49
  - lib/harnex/runtime/api_server.rb
47
50
  - lib/harnex/runtime/file_change_hook.rb