harnex 0.2.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/GUIDE.md +242 -0
  3. data/LICENSE +21 -0
  4. data/README.md +119 -0
  5. data/TECHNICAL.md +595 -0
  6. data/bin/harnex +18 -0
  7. data/lib/harnex/adapters/base.rb +134 -0
  8. data/lib/harnex/adapters/claude.rb +105 -0
  9. data/lib/harnex/adapters/codex.rb +112 -0
  10. data/lib/harnex/adapters/generic.rb +14 -0
  11. data/lib/harnex/adapters.rb +32 -0
  12. data/lib/harnex/cli.rb +115 -0
  13. data/lib/harnex/commands/guide.rb +23 -0
  14. data/lib/harnex/commands/logs.rb +184 -0
  15. data/lib/harnex/commands/pane.rb +251 -0
  16. data/lib/harnex/commands/recipes.rb +104 -0
  17. data/lib/harnex/commands/run.rb +384 -0
  18. data/lib/harnex/commands/send.rb +415 -0
  19. data/lib/harnex/commands/skills.rb +163 -0
  20. data/lib/harnex/commands/status.rb +171 -0
  21. data/lib/harnex/commands/stop.rb +127 -0
  22. data/lib/harnex/commands/wait.rb +165 -0
  23. data/lib/harnex/core.rb +286 -0
  24. data/lib/harnex/runtime/api_server.rb +187 -0
  25. data/lib/harnex/runtime/file_change_hook.rb +111 -0
  26. data/lib/harnex/runtime/inbox.rb +207 -0
  27. data/lib/harnex/runtime/message.rb +23 -0
  28. data/lib/harnex/runtime/session.rb +380 -0
  29. data/lib/harnex/runtime/session_state.rb +55 -0
  30. data/lib/harnex/version.rb +3 -0
  31. data/lib/harnex/watcher/inotify.rb +43 -0
  32. data/lib/harnex/watcher/polling.rb +92 -0
  33. data/lib/harnex/watcher.rb +24 -0
  34. data/lib/harnex.rb +25 -0
  35. data/recipes/01_fire_and_watch.md +82 -0
  36. data/recipes/02_chain_implement.md +115 -0
  37. data/skills/chain-implement/SKILL.md +234 -0
  38. data/skills/close/SKILL.md +47 -0
  39. data/skills/dispatch/SKILL.md +171 -0
  40. data/skills/harnex/SKILL.md +304 -0
  41. data/skills/open/SKILL.md +32 -0
  42. metadata +88 -0
@@ -0,0 +1,384 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "shellwords"
4
+
5
+ module Harnex
6
+ class Runner
7
+ DEFAULT_TIMEOUT = 5.0
8
+ KNOWN_FLAGS = %w[
9
+ --id --description --detach --tmux --host --port --watch --context --timeout --inbox-ttl --help
10
+ ].freeze
11
+ VALUE_FLAGS = %w[
12
+ --id --description --host --port --watch --context --timeout --inbox-ttl
13
+ ].freeze
14
+
15
+ def self.usage(program_name = "harnex run")
16
+ <<~TEXT
17
+ Usage: #{program_name} <cli> [options] [--] [cli-args...]
18
+
19
+ Options:
20
+ --id ID Session identifier (default: random two-word ID)
21
+ --description TEXT Short description of what this session is doing
22
+ --detach Start session in background and return JSON on stdout
23
+ --tmux [NAME] Run in a tmux window (implies --detach)
24
+ --host HOST Bind host for the local API (default: #{DEFAULT_HOST})
25
+ --port PORT Force a specific local API port
26
+ --watch PATH Auto-send a file-change hook on modification
27
+ --context TEXT Inject as the initial prompt (prepends session header)
28
+ --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
29
+ --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
30
+ -h, --help Show this help
31
+
32
+ Notes:
33
+ CLIs with smart prompt detection: #{Adapters.known.join(', ')}
34
+ Any other CLI name is launched with generic wrapping.
35
+ Wrapper options may appear before or after <cli>.
36
+ TEXT
37
+ end
38
+
39
+ def initialize(argv)
40
+ @argv = argv.dup
41
+ @options = {
42
+ id: nil,
43
+ description: nil,
44
+ host: DEFAULT_HOST,
45
+ port: nil,
46
+ watch: nil,
47
+ context: nil,
48
+ detach: false,
49
+ tmux: false,
50
+ tmux_name: nil,
51
+ timeout: DEFAULT_TIMEOUT,
52
+ inbox_ttl: default_inbox_ttl,
53
+ help: false
54
+ }
55
+ end
56
+
57
+ def run
58
+ cli_name, child_args = extract_wrapper_options(@argv)
59
+ if @options[:help]
60
+ puts self.class.usage
61
+ return 0
62
+ end
63
+
64
+ raise OptionParser::MissingArgument, "cli" if cli_name.nil?
65
+
66
+ repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
67
+ @options[:id] ||= Harnex.generate_id(repo_root)
68
+ validate_unique_id!(repo_root)
69
+ effective_child_args = apply_context(child_args)
70
+ adapter = Harnex.build_adapter(cli_name, effective_child_args)
71
+ @options[:detach] = true if @options[:tmux]
72
+
73
+ if @options[:detach]
74
+ run_detached(adapter, cli_name, child_args, repo_root)
75
+ else
76
+ run_foreground(adapter, repo_root)
77
+ end
78
+ end
79
+
80
+ def run_foreground(adapter, repo_root)
81
+ session = build_session(adapter, repo_root)
82
+ session.validate_binary!
83
+ warn("harnex: session #{session.id} on #{session.host}:#{session.port}")
84
+ session.run(validate_binary: false)
85
+ end
86
+
87
+ def run_detached(adapter, cli_name, child_args, repo_root)
88
+ Session.validate_binary!(adapter.build_command)
89
+
90
+ if @options[:tmux]
91
+ run_in_tmux(cli_name, child_args, repo_root)
92
+ else
93
+ run_headless(adapter, repo_root)
94
+ end
95
+ end
96
+
97
+ def run_in_tmux(cli_name, child_args, repo_root)
98
+ harnex_bin = File.expand_path("../../../bin/harnex", __dir__)
99
+ tmux_cmd = [harnex_bin, "run", cli_name]
100
+ tmux_cmd += ["--id", @options[:id]]
101
+ tmux_cmd += ["--description", @options[:description]] if @options[:description]
102
+ tmux_cmd += ["--host", @options[:host]]
103
+ tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
104
+ tmux_cmd += ["--watch", @options[:watch]] if @options[:watch]
105
+ tmux_cmd += ["--context", @options[:context]] if @options[:context]
106
+ tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
107
+ tmux_cmd += ["--"] + child_args unless child_args.empty?
108
+
109
+ window_name = @options[:tmux_name] || @options[:id]
110
+ shell_cmd = tmux_cmd.map { |arg| Shellwords.shellescape(arg) }.join(" ")
111
+
112
+ started =
113
+ if ENV["TMUX"]
114
+ system("tmux", "new-window", "-n", window_name, "-d", shell_cmd)
115
+ else
116
+ system("tmux", "new-session", "-d", "-s", "harnex", "-n", window_name, shell_cmd)
117
+ end
118
+
119
+ raise "tmux failed to start #{cli_name.inspect}" unless started
120
+
121
+ registry = wait_for_registration(repo_root)
122
+ return registration_timeout(@options[:id]) unless registry
123
+ registry = annotate_tmux_registry(registry)
124
+
125
+ payload = {
126
+ ok: true,
127
+ id: @options[:id],
128
+ cli: cli_name,
129
+ pid: registry["pid"],
130
+ port: registry["port"],
131
+ mode: "tmux",
132
+ window: window_name,
133
+ output_log_path: Harnex.output_log_path(repo_root, @options[:id])
134
+ }
135
+ payload[:description] = @options[:description] if @options[:description]
136
+ puts JSON.generate(payload)
137
+ 0
138
+ end
139
+
140
+ def run_headless(adapter, repo_root)
141
+ log_dir = File.join(Harnex::STATE_DIR, "logs")
142
+ FileUtils.mkdir_p(log_dir)
143
+ log_path = File.join(log_dir, "#{@options[:id]}.log")
144
+
145
+ child_pid = fork do
146
+ Process.setsid
147
+ STDIN.reopen("/dev/null")
148
+ log_file = File.open(log_path, "a")
149
+ STDOUT.reopen(log_file)
150
+ STDERR.reopen(log_file)
151
+ STDOUT.sync = true
152
+ STDERR.sync = true
153
+
154
+ session = build_session(adapter, repo_root)
155
+ exit_code = session.run(validate_binary: false)
156
+ exit(exit_code || 1)
157
+ end
158
+
159
+ Process.detach(child_pid)
160
+
161
+ registry = wait_for_registration(repo_root)
162
+ return registration_timeout(@options[:id]) unless registry
163
+
164
+ payload = {
165
+ ok: true,
166
+ id: @options[:id],
167
+ cli: adapter.key,
168
+ pid: registry["pid"],
169
+ port: registry["port"],
170
+ mode: "headless",
171
+ log: log_path,
172
+ output_log_path: Harnex.output_log_path(repo_root, @options[:id])
173
+ }
174
+ payload[:description] = @options[:description] if @options[:description]
175
+ puts JSON.generate(payload)
176
+ 0
177
+ end
178
+
179
+ private
180
+
181
+ def validate_unique_id!(repo_root)
182
+ existing = Harnex.read_registry(repo_root, @options[:id])
183
+ return unless existing
184
+
185
+ raise "harnex run: session #{@options[:id].inspect} is already active " \
186
+ "(pid #{existing['pid']}, port #{existing['port']}). " \
187
+ "Use a different --id or stop the existing session first."
188
+ end
189
+
190
+ def build_session(adapter, repo_root)
191
+ watch = Harnex.build_watch_config(@options[:watch], repo_root)
192
+ Session.new(
193
+ adapter: adapter,
194
+ command: adapter.build_command,
195
+ repo_root: repo_root,
196
+ host: @options[:host],
197
+ port: @options[:port],
198
+ id: @options[:id],
199
+ watch: watch,
200
+ description: @options[:description],
201
+ inbox_ttl: @options[:inbox_ttl]
202
+ )
203
+ end
204
+
205
+ def adapter_repo_path(cli_name, child_args)
206
+ Harnex.build_adapter(cli_name, child_args).infer_repo_path(child_args)
207
+ end
208
+
209
+ def apply_context(child_args)
210
+ return child_args unless @options[:context]
211
+
212
+ context = "[harnex session id=#{@options[:id]}] #{@options[:context]}"
213
+ child_args + [context]
214
+ end
215
+
216
+ def wait_for_registration(repo_root)
217
+ deadline = Time.now + @options[:timeout]
218
+ loop do
219
+ registry = Harnex.read_registry(repo_root, @options[:id])
220
+ return registry if registry
221
+ return nil if Time.now >= deadline
222
+
223
+ sleep 0.1
224
+ end
225
+ end
226
+
227
+ def annotate_tmux_registry(registry)
228
+ discovery = Harnex.tmux_pane_for_pid(registry["pid"])
229
+ return registry unless discovery
230
+
231
+ updated = registry.dup
232
+ updated["tmux_target"] = discovery.fetch(:target)
233
+ updated["tmux_session"] = discovery.fetch(:session_name)
234
+ updated["tmux_window"] = discovery.fetch(:window_name)
235
+
236
+ path = registry["registry_path"].to_s
237
+ if !path.empty? && File.exist?(path)
238
+ persisted = JSON.parse(File.read(path))
239
+ Harnex.write_registry(path, persisted.merge(updated))
240
+ end
241
+
242
+ updated
243
+ rescue JSON::ParserError
244
+ registry
245
+ end
246
+
247
+ def registration_timeout(id)
248
+ warn("harnex: detached session #{id} did not register within #{@options[:timeout]}s")
249
+ 124
250
+ end
251
+
252
+ def extract_wrapper_options(argv)
253
+ cli_name = nil
254
+ forwarded = []
255
+ index = 0
256
+
257
+ while index < argv.length
258
+ arg = argv[index]
259
+ case arg
260
+ when "--"
261
+ forwarded.concat(argv[(index + 1)..] || [])
262
+ break
263
+ when "-h", "--help"
264
+ @options[:help] = true
265
+ when "--id"
266
+ index += 1
267
+ @options[:id] = Harnex.normalize_id(required_option_value(arg, argv[index]))
268
+ when /\A--id=(.+)\z/
269
+ @options[:id] = Harnex.normalize_id(required_option_value("--id", Regexp.last_match(1)))
270
+ when "--description"
271
+ index += 1
272
+ @options[:description] = required_option_value(arg, argv[index])
273
+ when /\A--description=(.+)\z/
274
+ @options[:description] = required_option_value("--description", Regexp.last_match(1))
275
+ when "--detach"
276
+ @options[:detach] = true
277
+ when "--tmux"
278
+ @options[:tmux] = true
279
+ if tmux_name_arg?(argv, index, cli_name)
280
+ index += 1
281
+ @options[:tmux_name] = argv[index]
282
+ end
283
+ when /\A--tmux=(.+)\z/
284
+ @options[:tmux] = true
285
+ @options[:tmux_name] = Regexp.last_match(1)
286
+ when "--host"
287
+ index += 1
288
+ @options[:host] = required_option_value(arg, argv[index])
289
+ when /\A--host=(.+)\z/
290
+ @options[:host] = required_option_value("--host", Regexp.last_match(1))
291
+ when "--port"
292
+ index += 1
293
+ @options[:port] = Integer(required_option_value(arg, argv[index]))
294
+ when /\A--port=(.+)\z/
295
+ @options[:port] = Integer(required_option_value("--port", Regexp.last_match(1)))
296
+ when "--watch"
297
+ index += 1
298
+ @options[:watch] = required_option_value(arg, argv[index])
299
+ when /\A--watch=(.+)\z/
300
+ @options[:watch] = required_option_value("--watch", Regexp.last_match(1))
301
+ when "--context"
302
+ index += 1
303
+ @options[:context] = required_option_value(arg, argv[index])
304
+ when /\A--context=(.+)\z/
305
+ @options[:context] = required_option_value("--context", Regexp.last_match(1))
306
+ when "--timeout"
307
+ index += 1
308
+ @options[:timeout] = Float(required_option_value(arg, argv[index]))
309
+ when /\A--timeout=(.+)\z/
310
+ @options[:timeout] = Float(required_option_value("--timeout", Regexp.last_match(1)))
311
+ when "--inbox-ttl"
312
+ index += 1
313
+ @options[:inbox_ttl] = Float(required_option_value(arg, argv[index]))
314
+ when /\A--inbox-ttl=(.+)\z/
315
+ @options[:inbox_ttl] = Float(required_option_value("--inbox-ttl", Regexp.last_match(1)))
316
+ else
317
+ if cli_name.nil?
318
+ cli_name = arg
319
+ else
320
+ forwarded << arg
321
+ end
322
+ end
323
+ index += 1
324
+ end
325
+
326
+ [cli_name, forwarded]
327
+ end
328
+
329
+ def required_option_value(option_name, value)
330
+ raise OptionParser::MissingArgument, option_name if value.nil?
331
+ raise OptionParser::MissingArgument, option_name if value.match?(/\A-[A-Za-z]/)
332
+ return value unless value.start_with?("--")
333
+
334
+ flag = value.split("=", 2).first
335
+ raise OptionParser::MissingArgument, option_name if KNOWN_FLAGS.include?(flag)
336
+
337
+ value
338
+ end
339
+
340
+ def tmux_name_arg?(argv, index, cli_name)
341
+ value = argv[index + 1]
342
+ return false if value.nil? || value == "--" || wrapper_option_token?(value)
343
+ return true if cli_name
344
+
345
+ cli_candidate_after?(argv, index + 2)
346
+ end
347
+
348
+ def cli_candidate_after?(argv, index)
349
+ while index < argv.length
350
+ arg = argv[index]
351
+ case arg
352
+ when "--"
353
+ return false
354
+ when "-h", "--help", "--detach", "--tmux"
355
+ nil
356
+ when /\A--tmux=/
357
+ nil
358
+ when *VALUE_FLAGS
359
+ index += 1
360
+ when /\A--(?:id|description|host|port|watch|context|timeout|inbox-ttl)=/
361
+ nil
362
+ else
363
+ return true
364
+ end
365
+ index += 1
366
+ end
367
+
368
+ false
369
+ end
370
+
371
+ def wrapper_option_token?(arg)
372
+ KNOWN_FLAGS.include?(arg) ||
373
+ arg == "-h" ||
374
+ arg.start_with?("--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--context=", "--timeout=", "--inbox-ttl=")
375
+ end
376
+
377
+ def default_inbox_ttl
378
+ value = ENV["HARNEX_INBOX_TTL"]
379
+ return Inbox::DEFAULT_TTL.to_f if value.nil? || value.strip.empty?
380
+
381
+ Float(value)
382
+ end
383
+ end
384
+ end