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.
@@ -7,11 +7,12 @@ module Harnex
7
7
  DEFAULT_TIMEOUT = 5.0
8
8
  KNOWN_FLAGS = %w[
9
9
  --id --description --detach --tmux --host --port --watch --watch-file
10
- --stall-after --max-resumes --preset --context --timeout --inbox-ttl --help
10
+ --stall-after --max-resumes --preset --context --meta --summary-out
11
+ --timeout --inbox-ttl --legacy-pty --help
11
12
  ].freeze
12
13
  VALUE_FLAGS = %w[
13
14
  --id --description --host --port --watch --watch-file --stall-after
14
- --max-resumes --preset --context --timeout --inbox-ttl
15
+ --max-resumes --preset --context --meta --summary-out --timeout --inbox-ttl
15
16
  ].freeze
16
17
 
17
18
  def self.usage(program_name = "harnex run")
@@ -31,8 +32,13 @@ module Harnex
31
32
  --preset NAME Watch preset: impl, plan, gate (requires --watch)
32
33
  --watch-file PATH Auto-send a file-change hook on modification
33
34
  --context TEXT Inject as the initial prompt (prepends session header)
35
+ --meta JSON Attach parsed JSON metadata to the started event
36
+ --summary-out PATH Append dispatch telemetry summary JSONL to PATH
34
37
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
35
38
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
39
+ --legacy-pty (codex only) Use the legacy PTY adapter instead of
40
+ the JSON-RPC `app-server` adapter. Deprecated; will
41
+ be removed in 0.7.0.
36
42
  -h, --help Show this help
37
43
 
38
44
  Notes:
@@ -60,11 +66,14 @@ module Harnex
60
66
  preset: nil,
61
67
  watch: nil,
62
68
  context: nil,
69
+ meta: nil,
70
+ summary_out: nil,
63
71
  detach: false,
64
72
  tmux: false,
65
73
  tmux_name: nil,
66
74
  timeout: DEFAULT_TIMEOUT,
67
75
  inbox_ttl: default_inbox_ttl,
76
+ legacy_pty: false,
68
77
  help: false
69
78
  }
70
79
  end
@@ -79,10 +88,11 @@ module Harnex
79
88
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
80
89
 
81
90
  repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
91
+ @options[:summary_out] = resolve_summary_out(repo_root)
82
92
  @options[:id] ||= Harnex.generate_id(repo_root)
83
93
  validate_unique_id!(repo_root)
84
94
  effective_child_args = apply_context(child_args)
85
- adapter = Harnex.build_adapter(cli_name, effective_child_args)
95
+ adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
86
96
  @options[:detach] = true if @options[:tmux]
87
97
  validate_watch_mode!
88
98
  resolve_watch_preset!
@@ -137,7 +147,10 @@ module Harnex
137
147
  tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
138
148
  tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
139
149
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
150
+ tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
151
+ tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
140
152
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
153
+ tmux_cmd += ["--legacy-pty"] if @options[:legacy_pty]
141
154
  tmux_cmd += ["--"] + child_args unless child_args.empty?
142
155
 
143
156
  window_name = @options[:tmux_name] || @options[:id]
@@ -239,12 +252,14 @@ module Harnex
239
252
  id: @options[:id],
240
253
  watch: watch,
241
254
  description: @options[:description],
255
+ meta: @options[:meta],
256
+ summary_out: @options[:summary_out],
242
257
  inbox_ttl: @options[:inbox_ttl]
243
258
  )
244
259
  end
245
260
 
246
261
  def adapter_repo_path(cli_name, child_args)
247
- Harnex.build_adapter(cli_name, child_args).infer_repo_path(child_args)
262
+ Harnex.build_adapter(cli_name, child_args, legacy_pty: @options[:legacy_pty]).infer_repo_path(child_args)
248
263
  end
249
264
 
250
265
  def apply_context(child_args)
@@ -385,6 +400,16 @@ module Harnex
385
400
  @options[:context] = required_option_value(arg, argv[index])
386
401
  when /\A--context=(.+)\z/
387
402
  @options[:context] = required_option_value("--context", Regexp.last_match(1))
403
+ when "--meta"
404
+ index += 1
405
+ @options[:meta] = parse_meta(required_option_value(arg, argv[index]))
406
+ when /\A--meta=(.+)\z/
407
+ @options[:meta] = parse_meta(required_option_value("--meta", Regexp.last_match(1)))
408
+ when "--summary-out"
409
+ index += 1
410
+ @options[:summary_out] = required_option_value(arg, argv[index])
411
+ when /\A--summary-out=(.+)\z/
412
+ @options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
388
413
  when "--timeout"
389
414
  index += 1
390
415
  @options[:timeout] = Float(required_option_value(arg, argv[index]))
@@ -395,6 +420,8 @@ module Harnex
395
420
  @options[:inbox_ttl] = Float(required_option_value(arg, argv[index]))
396
421
  when /\A--inbox-ttl=(.+)\z/
397
422
  @options[:inbox_ttl] = Float(required_option_value("--inbox-ttl", Regexp.last_match(1)))
423
+ when "--legacy-pty"
424
+ @options[:legacy_pty] = true
398
425
  else
399
426
  if cli_name.nil?
400
427
  cli_name = arg
@@ -434,13 +461,13 @@ module Harnex
434
461
  case arg
435
462
  when "--"
436
463
  return false
437
- when "-h", "--help", "--detach", "--tmux"
464
+ when "-h", "--help", "--detach", "--tmux", "--legacy-pty"
438
465
  nil
439
466
  when /\A--tmux=/
440
467
  nil
441
468
  when *VALUE_FLAGS
442
469
  index += 1
443
- when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|timeout|inbox-ttl)=/
470
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|timeout|inbox-ttl)=/
444
471
  nil
445
472
  when /\A--preset=/
446
473
  nil
@@ -458,7 +485,8 @@ module Harnex
458
485
  arg == "-h" ||
459
486
  arg.start_with?(
460
487
  "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
461
- "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--timeout=", "--inbox-ttl="
488
+ "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
489
+ "--timeout=", "--inbox-ttl="
462
490
  )
463
491
  end
464
492
 
@@ -489,6 +517,22 @@ module Harnex
489
517
  raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
490
518
  end
491
519
 
520
+ def parse_meta(value)
521
+ parsed = JSON.parse(value)
522
+ return parsed if parsed.is_a?(Hash)
523
+
524
+ raise OptionParser::InvalidOption, "--meta must be a JSON object"
525
+ rescue JSON::ParserError => e
526
+ raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
527
+ end
528
+
529
+ def resolve_summary_out(repo_root)
530
+ configured = @options[:summary_out]
531
+ return Harnex.default_summary_out_path(repo_root) if configured.nil?
532
+
533
+ File.expand_path(configured, repo_root)
534
+ end
535
+
492
536
  def default_inbox_ttl
493
537
  value = ENV["HARNEX_INBOX_TTL"]
494
538
  return Inbox::DEFAULT_TTL.to_f if value.nil? || value.strip.empty?
@@ -7,14 +7,20 @@ module Harnex
7
7
  class Waiter
8
8
  POLL_INTERVAL = 0.5
9
9
 
10
+ EVENT_PREDICATES = %w[task_complete].freeze
11
+
10
12
  def self.usage(program_name = "harnex wait")
11
13
  <<~TEXT
12
14
  Usage: #{program_name} [options]
13
15
 
14
16
  Options:
15
17
  --id ID Session ID to wait for (required)
16
- --until STATE Wait until session reaches STATE (e.g. "prompt")
17
- Without --until, waits for session exit (default)
18
+ --until STATE Wait until session reaches STATE. Supported:
19
+ task_complete (events JSONL fires on
20
+ turn/completed; adapter-agnostic)
21
+ <other> (agent_state HTTP poll, e.g.
22
+ "prompt", "busy")
23
+ Without --until, waits for session exit (default).
18
24
  --repo PATH Resolve session using PATH's repo root (default: current repo)
19
25
  --timeout SECS Maximum time to wait in seconds (default: unlimited)
20
26
  -h, --help Show this help
@@ -42,7 +48,11 @@ module Harnex
42
48
  raise "--id is required for harnex wait" unless @options[:id]
43
49
 
44
50
  if @options[:until_state]
45
- wait_until_state
51
+ if EVENT_PREDICATES.include?(@options[:until_state])
52
+ wait_until_event(@options[:until_state])
53
+ else
54
+ wait_until_state
55
+ end
46
56
  else
47
57
  wait_until_exit
48
58
  end
@@ -50,6 +60,102 @@ module Harnex
50
60
 
51
61
  private
52
62
 
63
+ def wait_until_event(predicate)
64
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
65
+ events_path = Harnex.events_log_path(repo_root, @options[:id])
66
+ registry = Harnex.read_registry(repo_root, @options[:id])
67
+ start_time = Time.now
68
+ deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
69
+
70
+ unless registry || File.exist?(events_path)
71
+ warn("harnex wait: no session found with id #{@options[:id].inspect}")
72
+ return 1
73
+ end
74
+
75
+ offset = 0
76
+ task_complete_seen = false
77
+
78
+ # Replay existing events first — we may already be past the predicate.
79
+ if File.exist?(events_path)
80
+ File.open(events_path, "r") do |f|
81
+ f.each_line do |line|
82
+ offset = f.pos
83
+ event = parse_event(line)
84
+ next unless event
85
+ task_complete_seen = true if event["type"] == "task_complete"
86
+ if matches?(event, predicate, task_complete_seen)
87
+ return emit_event_match(event, start_time)
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ target_pid = registry && registry["pid"]
94
+
95
+ loop do
96
+ if target_pid && !Harnex.alive_pid?(target_pid)
97
+ waited = (Time.now - start_time).round(1)
98
+ puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
99
+ return 1
100
+ end
101
+
102
+ if deadline && Time.now >= deadline
103
+ waited = (Time.now - start_time).round(1)
104
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited)
105
+ return 124
106
+ end
107
+
108
+ if File.exist?(events_path) && File.size(events_path) > offset
109
+ File.open(events_path, "r") do |f|
110
+ f.seek(offset)
111
+ f.each_line do |line|
112
+ event = parse_event(line)
113
+ next unless event
114
+ task_complete_seen = true if event["type"] == "task_complete"
115
+ if matches?(event, predicate, task_complete_seen)
116
+ offset = f.pos
117
+ return emit_event_match(event, start_time)
118
+ end
119
+ end
120
+ offset = f.pos
121
+ end
122
+ end
123
+
124
+ sleep 0.1
125
+ end
126
+ end
127
+
128
+ def parse_event(line)
129
+ JSON.parse(line)
130
+ rescue JSON::ParserError
131
+ nil
132
+ end
133
+
134
+ def matches?(event, predicate, task_complete_seen)
135
+ type = event["type"]
136
+ case predicate
137
+ when "task_complete"
138
+ type == "task_complete"
139
+ when "prompt"
140
+ type == "task_complete" ||
141
+ (task_complete_seen && type == "agent_state" && event["state"] == "prompt")
142
+ else
143
+ false
144
+ end
145
+ end
146
+
147
+ def emit_event_match(event, start_time)
148
+ waited = (Time.now - start_time).round(1)
149
+ puts JSON.generate(
150
+ ok: true,
151
+ id: @options[:id],
152
+ event: event["type"],
153
+ seq: event["seq"],
154
+ waited_seconds: waited
155
+ )
156
+ 0
157
+ end
158
+
53
159
  def wait_until_state
54
160
  repo_root = Harnex.resolve_repo_root(@options[:repo_path])
55
161
  target_state = @options[:until_state]
data/lib/harnex/core.rb CHANGED
@@ -64,6 +64,67 @@ module Harnex
64
64
  seconds
65
65
  end
66
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
+
67
128
  def repo_key(repo_root)
68
129
  Digest::SHA256.hexdigest(repo_root)[0, 16]
69
130
  end
@@ -288,10 +349,10 @@ module Harnex
288
349
  false
289
350
  end
290
351
 
291
- def build_adapter(cli, argv)
352
+ def build_adapter(cli, argv, legacy_pty: false)
292
353
  raise ArgumentError, "cli is required" if cli.to_s.strip.empty?
293
354
 
294
- Adapters.build(cli, argv)
355
+ Adapters.build(cli, argv, legacy_pty: legacy_pty)
295
356
  end
296
357
 
297
358
  def session_cli(session)
@@ -316,4 +377,19 @@ module Harnex
316
377
  debounce_seconds: WATCH_DEBOUNCE_SECONDS
317
378
  )
318
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
319
395
  end