harnex 0.3.3 → 0.4.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.
@@ -6,10 +6,12 @@ module Harnex
6
6
  class Runner
7
7
  DEFAULT_TIMEOUT = 5.0
8
8
  KNOWN_FLAGS = %w[
9
- --id --description --detach --tmux --host --port --watch --context --timeout --inbox-ttl --help
9
+ --id --description --detach --tmux --host --port --watch --watch-file
10
+ --stall-after --max-resumes --preset --context --timeout --inbox-ttl --help
10
11
  ].freeze
11
12
  VALUE_FLAGS = %w[
12
- --id --description --host --port --watch --context --timeout --inbox-ttl
13
+ --id --description --host --port --watch --watch-file --stall-after
14
+ --max-resumes --preset --context --timeout --inbox-ttl
13
15
  ].freeze
14
16
 
15
17
  def self.usage(program_name = "harnex run")
@@ -23,13 +25,20 @@ module Harnex
23
25
  --tmux [NAME] Run in a tmux window (implies --detach)
24
26
  --host HOST Bind host for the local API (default: #{DEFAULT_HOST})
25
27
  --port PORT Force a specific local API port
26
- --watch PATH Auto-send a file-change hook on modification
28
+ --watch Enable blocking babysitter mode (foreground only)
29
+ --stall-after DUR Force-resume threshold (default: #{RunWatcher::DEFAULT_STALL_AFTER_S.to_i}s)
30
+ --max-resumes N Max forced resumes before escalation (default: #{RunWatcher::DEFAULT_MAX_RESUMES})
31
+ --preset NAME Watch preset: impl, plan, gate (requires --watch)
32
+ --watch-file PATH Auto-send a file-change hook on modification
27
33
  --context TEXT Inject as the initial prompt (prepends session header)
28
34
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
29
35
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
30
36
  -h, --help Show this help
31
37
 
32
38
  Notes:
39
+ Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
40
+ Bare `--watch` enables the babysitter.
41
+ Explicit --stall-after/--max-resumes values override --preset defaults.
33
42
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
34
43
  Any other CLI name is launched with generic wrapping.
35
44
  Wrapper options may appear before or after <cli>.
@@ -43,6 +52,12 @@ module Harnex
43
52
  description: nil,
44
53
  host: DEFAULT_HOST,
45
54
  port: nil,
55
+ watch_enabled: false,
56
+ stall_after_s: RunWatcher::DEFAULT_STALL_AFTER_S,
57
+ stall_after_explicit: false,
58
+ max_resumes: RunWatcher::DEFAULT_MAX_RESUMES,
59
+ max_resumes_explicit: false,
60
+ preset: nil,
46
61
  watch: nil,
47
62
  context: nil,
48
63
  detach: false,
@@ -69,8 +84,12 @@ module Harnex
69
84
  effective_child_args = apply_context(child_args)
70
85
  adapter = Harnex.build_adapter(cli_name, effective_child_args)
71
86
  @options[:detach] = true if @options[:tmux]
87
+ validate_watch_mode!
88
+ resolve_watch_preset!
72
89
 
73
- if @options[:detach]
90
+ if @options[:watch_enabled]
91
+ run_watch_mode(adapter, repo_root)
92
+ elsif @options[:detach]
74
93
  run_detached(adapter, cli_name, child_args, repo_root)
75
94
  else
76
95
  run_foreground(adapter, repo_root)
@@ -90,10 +109,25 @@ module Harnex
90
109
  if @options[:tmux]
91
110
  run_in_tmux(cli_name, child_args, repo_root)
92
111
  else
93
- run_headless(adapter, repo_root)
112
+ result = run_headless(adapter, repo_root)
113
+ result[:exit_code]
94
114
  end
95
115
  end
96
116
 
117
+ def run_watch_mode(adapter, repo_root)
118
+ Session.validate_binary!(adapter.build_command)
119
+
120
+ result = run_headless(adapter, repo_root, emit_payload: false)
121
+ return result[:exit_code] unless result[:ok]
122
+
123
+ RunWatcher.new(
124
+ id: @options[:id],
125
+ repo_root: repo_root,
126
+ stall_after_s: @options[:stall_after_s],
127
+ max_resumes: @options[:max_resumes]
128
+ ).run
129
+ end
130
+
97
131
  def run_in_tmux(cli_name, child_args, repo_root)
98
132
  harnex_bin = File.expand_path("../../../bin/harnex", __dir__)
99
133
  tmux_cmd = [harnex_bin, "run", cli_name]
@@ -101,7 +135,7 @@ module Harnex
101
135
  tmux_cmd += ["--description", @options[:description]] if @options[:description]
102
136
  tmux_cmd += ["--host", @options[:host]]
103
137
  tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
104
- tmux_cmd += ["--watch", @options[:watch]] if @options[:watch]
138
+ tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
105
139
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
106
140
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
107
141
  tmux_cmd += ["--"] + child_args unless child_args.empty?
@@ -137,7 +171,7 @@ module Harnex
137
171
  0
138
172
  end
139
173
 
140
- def run_headless(adapter, repo_root)
174
+ def run_headless(adapter, repo_root, emit_payload: true)
141
175
  log_dir = File.join(Harnex::STATE_DIR, "logs")
142
176
  FileUtils.mkdir_p(log_dir)
143
177
  log_path = File.join(log_dir, "#{@options[:id]}.log")
@@ -159,7 +193,7 @@ module Harnex
159
193
  Process.detach(child_pid)
160
194
 
161
195
  registry = wait_for_registration(repo_root)
162
- return registration_timeout(@options[:id]) unless registry
196
+ return { ok: false, exit_code: registration_timeout(@options[:id]) } unless registry
163
197
 
164
198
  payload = {
165
199
  ok: true,
@@ -172,12 +206,19 @@ module Harnex
172
206
  output_log_path: Harnex.output_log_path(repo_root, @options[:id])
173
207
  }
174
208
  payload[:description] = @options[:description] if @options[:description]
175
- puts JSON.generate(payload)
176
- 0
209
+ puts JSON.generate(payload) if emit_payload
210
+ { ok: true, exit_code: 0, registry: registry, payload: payload }
177
211
  end
178
212
 
179
213
  private
180
214
 
215
+ def validate_watch_mode!
216
+ return unless @options[:watch_enabled]
217
+ return unless @options[:detach]
218
+
219
+ raise OptionParser::InvalidOption, "--watch is only supported in foreground mode"
220
+ end
221
+
181
222
  def validate_unique_id!(repo_root)
182
223
  existing = Harnex.read_registry(repo_root, @options[:id])
183
224
  return unless existing
@@ -294,10 +335,51 @@ module Harnex
294
335
  when /\A--port=(.+)\z/
295
336
  @options[:port] = Integer(required_option_value("--port", Regexp.last_match(1)))
296
337
  when "--watch"
297
- index += 1
298
- @options[:watch] = required_option_value(arg, argv[index])
338
+ value = argv[index + 1]
339
+ if value.nil? || value == "--" || wrapper_option_token?(value)
340
+ @options[:watch_enabled] = true
341
+ else
342
+ index += 1
343
+ @options[:watch] = required_option_value(arg, argv[index])
344
+ end
299
345
  when /\A--watch=(.+)\z/
300
346
  @options[:watch] = required_option_value("--watch", Regexp.last_match(1))
347
+ when "--watch-file"
348
+ index += 1
349
+ @options[:watch] = required_option_value(arg, argv[index])
350
+ when /\A--watch-file=(.+)\z/
351
+ @options[:watch] = required_option_value("--watch-file", Regexp.last_match(1))
352
+ when "--stall-after"
353
+ index += 1
354
+ @options[:stall_after_s] = Harnex.parse_duration_seconds(
355
+ required_option_value(arg, argv[index]),
356
+ option_name: "--stall-after"
357
+ )
358
+ @options[:stall_after_explicit] = true
359
+ when /\A--stall-after=(.+)\z/
360
+ @options[:stall_after_s] = Harnex.parse_duration_seconds(
361
+ required_option_value("--stall-after", Regexp.last_match(1)),
362
+ option_name: "--stall-after"
363
+ )
364
+ @options[:stall_after_explicit] = true
365
+ when "--max-resumes"
366
+ index += 1
367
+ @options[:max_resumes] = parse_non_negative_integer(
368
+ required_option_value(arg, argv[index]),
369
+ option_name: "--max-resumes"
370
+ )
371
+ @options[:max_resumes_explicit] = true
372
+ when /\A--max-resumes=(.+)\z/
373
+ @options[:max_resumes] = parse_non_negative_integer(
374
+ required_option_value("--max-resumes", Regexp.last_match(1)),
375
+ option_name: "--max-resumes"
376
+ )
377
+ @options[:max_resumes_explicit] = true
378
+ when "--preset"
379
+ index += 1
380
+ @options[:preset] = required_option_value(arg, argv[index])
381
+ when /\A--preset=(.+)\z/
382
+ @options[:preset] = required_option_value("--preset", Regexp.last_match(1))
301
383
  when "--context"
302
384
  index += 1
303
385
  @options[:context] = required_option_value(arg, argv[index])
@@ -358,7 +440,9 @@ module Harnex
358
440
  nil
359
441
  when *VALUE_FLAGS
360
442
  index += 1
361
- when /\A--(?:id|description|host|port|watch|context|timeout|inbox-ttl)=/
443
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|timeout|inbox-ttl)=/
444
+ nil
445
+ when /\A--preset=/
362
446
  nil
363
447
  else
364
448
  return true
@@ -372,7 +456,37 @@ module Harnex
372
456
  def wrapper_option_token?(arg)
373
457
  KNOWN_FLAGS.include?(arg) ||
374
458
  arg == "-h" ||
375
- arg.start_with?("--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--context=", "--timeout=", "--inbox-ttl=")
459
+ arg.start_with?(
460
+ "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
461
+ "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--timeout=", "--inbox-ttl="
462
+ )
463
+ end
464
+
465
+ def resolve_watch_preset!
466
+ preset_name = @options[:preset]
467
+ return if preset_name.nil?
468
+
469
+ unless @options[:watch_enabled]
470
+ raise "harnex run: --preset requires --watch"
471
+ end
472
+
473
+ preset = WatchPresets.fetch(preset_name)
474
+ unless preset
475
+ valid = WatchPresets.valid_names.join(", ")
476
+ raise "harnex run: unknown --preset #{preset_name.inspect} (valid: #{valid})"
477
+ end
478
+
479
+ @options[:stall_after_s] = preset[:stall_after_s] unless @options[:stall_after_explicit]
480
+ @options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
481
+ end
482
+
483
+ def parse_non_negative_integer(value, option_name:)
484
+ integer = Integer(value)
485
+ raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
486
+
487
+ integer
488
+ rescue ArgumentError
489
+ raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
376
490
  end
377
491
 
378
492
  def default_inbox_ttl
@@ -4,20 +4,26 @@ module Harnex
4
4
  class Skills
5
5
  SKILLS_ROOT = File.expand_path("../../../../skills", __FILE__)
6
6
  INSTALL_SKILLS = %w[harnex-dispatch harnex-chain harnex-buddy].freeze
7
- DEPRECATED_SKILLS = %w[dispatch chain-implement].freeze
7
+ DEPRECATED_SKILLS = %w[harnex dispatch chain-implement].freeze
8
+ SKILL_ALIASES = {
9
+ "harnex" => "harnex-dispatch",
10
+ "dispatch" => "harnex-dispatch",
11
+ "chain-implement" => "harnex-chain"
12
+ }.freeze
8
13
 
9
14
  def self.usage
10
15
  <<~TEXT
11
- Usage: harnex skills <subcommand> [--local]
16
+ Usage: harnex skills <subcommand> [SKILL...] [--local]
12
17
 
13
18
  Subcommands:
14
- install Install bundled skills (globally by default)
19
+ install Install bundled skills (globally by default; optional skill names)
15
20
  uninstall Remove installed skills (globally by default)
16
21
 
17
22
  Options:
18
23
  --local Target the current repo instead of global ~/.claude/
19
24
 
20
25
  Installs: #{INSTALL_SKILLS.join(', ')}
26
+ Aliases: harnex|dispatch -> harnex-dispatch, chain-implement -> harnex-chain
21
27
 
22
28
  By default, copies each skill to ~/.claude/skills/<skill>/
23
29
  and symlinks ~/.codex/skills/<skill> to it.
@@ -35,12 +41,13 @@ module Harnex
35
41
  subcommand = @argv.shift
36
42
  case subcommand
37
43
  when "install"
38
- local, help = parse_args(@argv)
44
+ local, help, requested_skills = parse_args(@argv, allow_positional: true)
39
45
  return (puts self.class.usage; 0) if help
40
46
 
41
47
  remove_deprecated(local)
48
+ install_skills = requested_skills.empty? ? INSTALL_SKILLS : canonical_skill_names(requested_skills)
42
49
 
43
- INSTALL_SKILLS.each do |skill_name|
50
+ install_skills.each do |skill_name|
44
51
  skill_source = resolve_skill_source(skill_name)
45
52
  unless skill_source
46
53
  return missing_skill(skill_name)
@@ -51,7 +58,7 @@ module Harnex
51
58
  end
52
59
  0
53
60
  when "uninstall"
54
- local, help = parse_args(@argv)
61
+ local, help, = parse_args(@argv)
55
62
  return (puts self.class.usage; 0) if help
56
63
 
57
64
  (INSTALL_SKILLS + DEPRECATED_SKILLS).each do |skill_name|
@@ -70,9 +77,10 @@ module Harnex
70
77
 
71
78
  private
72
79
 
73
- def parse_args(args)
80
+ def parse_args(args, allow_positional: false)
74
81
  local = false
75
82
  help = false
83
+ positional = []
76
84
 
77
85
  args.each do |arg|
78
86
  case arg
@@ -83,12 +91,16 @@ module Harnex
83
91
  when /\A-/
84
92
  raise "harnex skills: unknown option #{arg.inspect}"
85
93
  else
86
- warn("harnex skills: unexpected argument #{arg.inspect}")
87
- raise "harnex skills takes no positional arguments"
94
+ if allow_positional
95
+ positional << arg
96
+ else
97
+ warn("harnex skills: unexpected argument #{arg.inspect}")
98
+ raise "harnex skills takes no positional arguments"
99
+ end
88
100
  end
89
101
  end
90
102
 
91
- [local, help]
103
+ [local, help, positional]
92
104
  end
93
105
 
94
106
  def resolve_skill_source(skill_name)
@@ -107,6 +119,14 @@ module Harnex
107
119
  end
108
120
  end
109
121
 
122
+ def canonical_skill_names(skill_names)
123
+ skill_names.map { |name| canonical_skill_name(name) }.uniq
124
+ end
125
+
126
+ def canonical_skill_name(skill_name)
127
+ SKILL_ALIASES.fetch(skill_name, skill_name)
128
+ end
129
+
110
130
  def install_local(skill_name, skill_source)
111
131
  repo_root = Harnex.resolve_repo_root(Dir.pwd)
112
132
  claude_dir = File.join(repo_root, ".claude", "skills", skill_name)
@@ -98,7 +98,7 @@ module Harnex
98
98
  end
99
99
 
100
100
  def render_table(sessions)
101
- columns = ["ID", "CLI", "PID", "PORT", "AGE", "STATE", "REPO", "DESC"]
101
+ columns = ["ID", "CLI", "PID", "PORT", "AGE", "IDLE", "STATE", "REPO", "DESC"]
102
102
 
103
103
  rows = sessions.map { |session| table_row(session, columns) }
104
104
  widths = columns.to_h { |column| [column, ([column.length] + rows.map { |row| row.fetch(column).length }).max] }
@@ -117,6 +117,7 @@ module Harnex
117
117
  "PID" => session["pid"].to_s,
118
118
  "PORT" => session["port"].to_s,
119
119
  "AGE" => timeago(session["started_at"]),
120
+ "IDLE" => format_idle(session["log_idle_s"]),
120
121
  "STATE" => session.dig("input_state", "state").to_s.empty? ? "-" : session.dig("input_state", "state").to_s,
121
122
  "DESC" => truncate(session["description"])
122
123
  }
@@ -133,7 +134,22 @@ module Harnex
133
134
 
134
135
  seconds = (Time.now - Time.parse(timestamp.to_s)).to_i
135
136
  seconds = 0 if seconds.negative?
137
+ compact_duration(seconds)
138
+ rescue StandardError
139
+ timestamp.to_s
140
+ end
141
+
142
+ def format_idle(idle_seconds)
143
+ return "-" if idle_seconds.nil?
144
+
145
+ seconds = Integer(idle_seconds)
146
+ seconds = 0 if seconds.negative?
147
+ compact_duration(seconds)
148
+ rescue StandardError
149
+ "-"
150
+ end
136
151
 
152
+ def compact_duration(seconds)
137
153
  case seconds
138
154
  when 0...60
139
155
  "#{seconds}s"
@@ -144,8 +160,6 @@ module Harnex
144
160
  else
145
161
  "#{seconds / 86_400}d"
146
162
  end
147
- rescue StandardError
148
- timestamp.to_s
149
163
  end
150
164
 
151
165
  def truncate(value)
@@ -0,0 +1,209 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Harnex
6
+ class RunWatcher
7
+ DEFAULT_STALL_AFTER_S = 8 * 60.0
8
+ DEFAULT_MAX_RESUMES = 1
9
+ POLL_INTERVAL_S = 60.0
10
+ MAX_STATUS_ERRORS = 3
11
+ RESUME_TEXT = "resume"
12
+
13
+ def initialize(
14
+ id:,
15
+ repo_root:,
16
+ stall_after_s: DEFAULT_STALL_AFTER_S,
17
+ max_resumes: DEFAULT_MAX_RESUMES,
18
+ poll_interval_s: POLL_INTERVAL_S,
19
+ sleeper: nil,
20
+ monotonic_clock: nil,
21
+ out: $stdout,
22
+ err: $stderr
23
+ )
24
+ @id = Harnex.normalize_id(id)
25
+ @repo_root = repo_root
26
+ @stall_after_s = Float(stall_after_s)
27
+ @max_resumes = Integer(max_resumes)
28
+ @poll_interval_s = Float(poll_interval_s)
29
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
30
+ @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
31
+ @out = out
32
+ @err = err
33
+ end
34
+
35
+ def run
36
+ polls = 0
37
+ resumes = 0
38
+ final_state = "unknown"
39
+ outcome = :error
40
+ status_errors = 0
41
+ start_at = now
42
+
43
+ @out.puts(
44
+ "harnex watch: id=#{@id} stall-after=#{format_duration(@stall_after_s)} " \
45
+ "max-resumes=#{@max_resumes} poll=#{format_duration(@poll_interval_s)}"
46
+ )
47
+
48
+ loop do
49
+ polls += 1
50
+ snapshot = fetch_snapshot
51
+
52
+ case snapshot[:kind]
53
+ when :exited
54
+ final_state = "exited"
55
+ outcome = :exited
56
+ @out.puts("harnex watch: session exited")
57
+ break
58
+ when :error
59
+ if snapshot[:fatal]
60
+ @err.puts("harnex watch: #{snapshot[:error]}")
61
+ outcome = :error
62
+ break
63
+ end
64
+
65
+ status_errors += 1
66
+ if status_errors >= MAX_STATUS_ERRORS
67
+ @err.puts("harnex watch: #{snapshot[:error]} (status retry limit reached)")
68
+ outcome = :error
69
+ break
70
+ end
71
+ when :status
72
+ status_errors = 0
73
+ final_state = snapshot[:agent_state]
74
+
75
+ if snapshot[:stalled]
76
+ if resumes < @max_resumes
77
+ send_resume(snapshot[:registry])
78
+ resumes += 1
79
+ @out.puts(
80
+ "harnex watch: resume #{resumes}/#{@max_resumes} " \
81
+ "(idle=#{format_duration(snapshot[:idle_seconds])}, state=#{final_state})"
82
+ )
83
+ else
84
+ outcome = :escalated
85
+ @out.puts("harnex watch: max resumes reached, escalating")
86
+ break
87
+ end
88
+ end
89
+ end
90
+
91
+ @sleeper.call(@poll_interval_s)
92
+ end
93
+
94
+ elapsed = (now - start_at).round(1)
95
+ @out.puts(
96
+ "harnex watch: summary id=#{@id} polls=#{polls} resumes=#{resumes} " \
97
+ "final_state=#{final_state} outcome=#{outcome} elapsed_s=#{elapsed}"
98
+ )
99
+ outcome_to_exit_code(outcome)
100
+ rescue StandardError => e
101
+ @err.puts("harnex watch: #{e.message}")
102
+ 1
103
+ end
104
+
105
+ private
106
+
107
+ def fetch_snapshot
108
+ registry = Harnex.read_registry(@repo_root, @id)
109
+ return { kind: :exited } unless registry
110
+
111
+ status = fetch_status(registry)
112
+ return status if status[:kind] == :error
113
+
114
+ payload = status[:payload]
115
+ unless payload.key?("log_idle_s")
116
+ return {
117
+ kind: :error,
118
+ fatal: true,
119
+ error: "status payload missing log_idle_s; upgrade to a Layer-1+ harnex build"
120
+ }
121
+ end
122
+
123
+ agent_state = payload["agent_state"].to_s.strip
124
+ return { kind: :exited } if agent_state == "exited"
125
+
126
+ idle_seconds = parse_idle_seconds(payload["log_idle_s"])
127
+ {
128
+ kind: :status,
129
+ registry: registry,
130
+ agent_state: agent_state.empty? ? "unknown" : agent_state,
131
+ idle_seconds: idle_seconds,
132
+ stalled: !idle_seconds.nil? && idle_seconds >= @stall_after_s
133
+ }
134
+ end
135
+
136
+ def fetch_status(registry)
137
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/status")
138
+ request = Net::HTTP::Get.new(uri)
139
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
140
+
141
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
142
+ http.request(request)
143
+ end
144
+
145
+ unless response.is_a?(Net::HTTPSuccess)
146
+ return { kind: :error, error: "status request failed with HTTP #{response.code} for session #{@id}" }
147
+ end
148
+
149
+ { kind: :status_payload, payload: JSON.parse(response.body) }
150
+ rescue StandardError => e
151
+ { kind: :error, error: "status request failed for session #{@id}: #{e.message}" }
152
+ end
153
+
154
+ def send_resume(registry)
155
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
156
+ request = Net::HTTP::Post.new(uri)
157
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
158
+ request["Content-Type"] = "application/json"
159
+ request.body = JSON.generate(
160
+ text: RESUME_TEXT,
161
+ submit: true,
162
+ enter_only: false,
163
+ force: true
164
+ )
165
+
166
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
167
+ http.request(request)
168
+ end
169
+
170
+ return if response.is_a?(Net::HTTPSuccess)
171
+
172
+ raise "resume send failed with HTTP #{response.code} for session #{@id}"
173
+ rescue StandardError => e
174
+ raise "resume send failed for session #{@id}: #{e.message}"
175
+ end
176
+
177
+ def parse_idle_seconds(value)
178
+ return nil if value.nil?
179
+
180
+ seconds = Integer(value)
181
+ seconds.negative? ? 0 : seconds
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ def outcome_to_exit_code(outcome)
187
+ case outcome
188
+ when :exited
189
+ 0
190
+ when :escalated
191
+ 2
192
+ else
193
+ 1
194
+ end
195
+ end
196
+
197
+ def format_duration(seconds)
198
+ value = seconds.to_f
199
+ return "#{value.round(1)}s" if value < 60
200
+ return "#{(value / 60).round(1)}m" if value < 3600
201
+
202
+ "#{(value / 3600).round(1)}h"
203
+ end
204
+
205
+ def now
206
+ @monotonic_clock.call
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,17 @@
1
+ module Harnex
2
+ module WatchPresets
3
+ TABLE = {
4
+ "impl" => { stall_after_s: 8 * 60.0, max_resumes: 1 }.freeze,
5
+ "plan" => { stall_after_s: 3 * 60.0, max_resumes: 2 }.freeze,
6
+ "gate" => { stall_after_s: 15 * 60.0, max_resumes: 0 }.freeze
7
+ }.freeze
8
+
9
+ def self.fetch(name)
10
+ TABLE[name]
11
+ end
12
+
13
+ def self.valid_names
14
+ TABLE.keys
15
+ end
16
+ end
17
+ end
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,32 @@ 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
+
40
67
  def repo_key(repo_root)
41
68
  Digest::SHA256.hexdigest(repo_root)[0, 16]
42
69
  end
@@ -113,6 +140,12 @@ module Harnex
113
140
  File.join(output_dir, "#{session_file_slug(repo_root, id)}.log")
114
141
  end
115
142
 
143
+ def events_log_path(repo_root, id)
144
+ events_dir = File.join(STATE_DIR, "events")
145
+ FileUtils.mkdir_p(events_dir)
146
+ File.join(events_dir, "#{session_file_slug(repo_root, id)}.jsonl")
147
+ end
148
+
116
149
  def session_file_slug(repo_root, id)
117
150
  slug = id_key(id)
118
151
  slug = "default" if slug.empty?