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.
- checksums.yaml +7 -0
- data/GUIDE.md +242 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/TECHNICAL.md +595 -0
- data/bin/harnex +18 -0
- data/lib/harnex/adapters/base.rb +134 -0
- data/lib/harnex/adapters/claude.rb +105 -0
- data/lib/harnex/adapters/codex.rb +112 -0
- data/lib/harnex/adapters/generic.rb +14 -0
- data/lib/harnex/adapters.rb +32 -0
- data/lib/harnex/cli.rb +115 -0
- data/lib/harnex/commands/guide.rb +23 -0
- data/lib/harnex/commands/logs.rb +184 -0
- data/lib/harnex/commands/pane.rb +251 -0
- data/lib/harnex/commands/recipes.rb +104 -0
- data/lib/harnex/commands/run.rb +384 -0
- data/lib/harnex/commands/send.rb +415 -0
- data/lib/harnex/commands/skills.rb +163 -0
- data/lib/harnex/commands/status.rb +171 -0
- data/lib/harnex/commands/stop.rb +127 -0
- data/lib/harnex/commands/wait.rb +165 -0
- data/lib/harnex/core.rb +286 -0
- data/lib/harnex/runtime/api_server.rb +187 -0
- data/lib/harnex/runtime/file_change_hook.rb +111 -0
- data/lib/harnex/runtime/inbox.rb +207 -0
- data/lib/harnex/runtime/message.rb +23 -0
- data/lib/harnex/runtime/session.rb +380 -0
- data/lib/harnex/runtime/session_state.rb +55 -0
- data/lib/harnex/version.rb +3 -0
- data/lib/harnex/watcher/inotify.rb +43 -0
- data/lib/harnex/watcher/polling.rb +92 -0
- data/lib/harnex/watcher.rb +24 -0
- data/lib/harnex.rb +25 -0
- data/recipes/01_fire_and_watch.md +82 -0
- data/recipes/02_chain_implement.md +115 -0
- data/skills/chain-implement/SKILL.md +234 -0
- data/skills/close/SKILL.md +47 -0
- data/skills/dispatch/SKILL.md +171 -0
- data/skills/harnex/SKILL.md +304 -0
- data/skills/open/SKILL.md +32 -0
- 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
|