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,171 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "optparse"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module Harnex
8
+ class Status
9
+ DESCRIPTION_WIDTH = 30
10
+ REPO_WIDTH = 20
11
+
12
+ def self.usage(program_name = "harnex status")
13
+ <<~TEXT
14
+ Usage: #{program_name} [options]
15
+
16
+ Options:
17
+ --id ID Show a specific session
18
+ --repo PATH Filter to PATH's repo root (default: current repo)
19
+ --all List sessions across all repos
20
+ --json Output JSON instead of a table
21
+ -h, --help Show this help
22
+ TEXT
23
+ end
24
+
25
+ def initialize(argv)
26
+ @argv = argv.dup
27
+ @options = {
28
+ id: nil,
29
+ repo_path: Dir.pwd,
30
+ all: false,
31
+ json: false,
32
+ help: false
33
+ }
34
+ end
35
+
36
+ def run
37
+ parser.parse!(@argv)
38
+ if @options[:help]
39
+ puts self.class.usage
40
+ return 0
41
+ end
42
+
43
+ sessions = load_sessions
44
+ if @options[:json]
45
+ puts JSON.generate(sessions)
46
+ return 0
47
+ end
48
+
49
+ if sessions.empty?
50
+ if @options[:all]
51
+ puts "No active harnex sessions."
52
+ else
53
+ puts "No active harnex sessions for #{Harnex.resolve_repo_root(@options[:repo_path])}."
54
+ end
55
+ return 0
56
+ end
57
+
58
+ puts render_table(sessions)
59
+ 0
60
+ end
61
+
62
+ private
63
+
64
+ def parser
65
+ @parser ||= OptionParser.new do |opts|
66
+ opts.banner = "Usage: harnex status [options]"
67
+ opts.on("--id ID", "Show a specific session") { |value| @options[:id] = Harnex.normalize_id(value) }
68
+ opts.on("--repo PATH", "Filter to PATH's repo root") { |value| @options[:repo_path] = value }
69
+ opts.on("--all", "List sessions across all repos") { @options[:all] = true }
70
+ opts.on("--json", "Output JSON instead of a table") { @options[:json] = true }
71
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
72
+ end
73
+ end
74
+
75
+ def load_sessions
76
+ repo_root = @options[:all] ? nil : Harnex.resolve_repo_root(@options[:repo_path])
77
+ sessions = Harnex.active_sessions(repo_root, id: @options[:id])
78
+
79
+ sessions.map { |session| load_live_status(session) }
80
+ .sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }
81
+ .reverse
82
+ end
83
+
84
+ def load_live_status(session)
85
+ uri = URI("http://#{session.fetch('host')}:#{session.fetch('port')}/status")
86
+ request = Net::HTTP::Get.new(uri)
87
+ request["Authorization"] = "Bearer #{session['token']}" if session["token"]
88
+
89
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 0.25, read_timeout: 0.25) do |http|
90
+ http.request(request)
91
+ end
92
+
93
+ return session unless response.is_a?(Net::HTTPSuccess)
94
+
95
+ session.merge(JSON.parse(response.body))
96
+ rescue StandardError
97
+ session
98
+ end
99
+
100
+ def render_table(sessions)
101
+ columns = ["ID", "CLI", "PID", "PORT", "AGE", "STATE", "REPO", "DESC"]
102
+
103
+ rows = sessions.map { |session| table_row(session, columns) }
104
+ widths = columns.to_h { |column| [column, ([column.length] + rows.map { |row| row.fetch(column).length }).max] }
105
+
106
+ lines = []
107
+ lines << format_row(columns.to_h { |column| [column, column] }, columns, widths)
108
+ lines << format_row(columns.to_h { |column| [column, "-" * widths.fetch(column)] }, columns, widths)
109
+ lines.concat(rows.map { |row| format_row(row, columns, widths) })
110
+ lines.join("\n")
111
+ end
112
+
113
+ def table_row(session, columns)
114
+ row = {
115
+ "ID" => session["id"].to_s,
116
+ "CLI" => Harnex.session_cli(session).empty? ? "-" : Harnex.session_cli(session),
117
+ "PID" => session["pid"].to_s,
118
+ "PORT" => session["port"].to_s,
119
+ "AGE" => timeago(session["started_at"]),
120
+ "STATE" => session.dig("input_state", "state").to_s.empty? ? "-" : session.dig("input_state", "state").to_s,
121
+ "DESC" => truncate(session["description"])
122
+ }
123
+ row["REPO"] = truncate_repo(session["repo_root"])
124
+ row
125
+ end
126
+
127
+ def format_row(row, columns, widths)
128
+ columns.map { |column| row.fetch(column).ljust(widths.fetch(column)) }.join(" ")
129
+ end
130
+
131
+ def timeago(timestamp)
132
+ return "-" if timestamp.to_s.empty?
133
+
134
+ seconds = (Time.now - Time.parse(timestamp.to_s)).to_i
135
+ seconds = 0 if seconds.negative?
136
+
137
+ case seconds
138
+ when 0...60
139
+ "#{seconds}s"
140
+ when 60...3600
141
+ "#{seconds / 60}m"
142
+ when 3600...86_400
143
+ "#{seconds / 3600}h"
144
+ else
145
+ "#{seconds / 86_400}d"
146
+ end
147
+ rescue StandardError
148
+ timestamp.to_s
149
+ end
150
+
151
+ def truncate(value)
152
+ text = value.to_s
153
+ return "-" if text.empty?
154
+ return text if text.length <= DESCRIPTION_WIDTH
155
+
156
+ "#{text[0, DESCRIPTION_WIDTH - 3]}..."
157
+ end
158
+
159
+ def truncate_repo(path)
160
+ text = display_path(path)
161
+ return "-" if text.empty?
162
+ return text if text.length <= REPO_WIDTH
163
+
164
+ "..#{text[-(REPO_WIDTH - 2)..]}"
165
+ end
166
+
167
+ def display_path(path)
168
+ path.to_s.sub(/\A#{Regexp.escape(Dir.home)}/, "~")
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,127 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "optparse"
4
+ require "uri"
5
+
6
+ module Harnex
7
+ class Stopper
8
+ DEFAULT_TIMEOUT = 5.0
9
+ MIN_HTTP_TIMEOUT = 0.1
10
+
11
+ class TimeoutError < RuntimeError; end
12
+
13
+ def self.usage(program_name = "harnex stop")
14
+ <<~TEXT
15
+ Usage: #{program_name} [options]
16
+
17
+ Options:
18
+ --id ID Session ID to stop (required)
19
+ --repo PATH Resolve the session using PATH's repo root (default: current repo)
20
+ --cli CLI Filter by CLI type
21
+ --timeout S How long to retry transient API failures (default: #{DEFAULT_TIMEOUT})
22
+ -h, --help Show this help
23
+
24
+ Sends the adapter stop sequence to the session.
25
+ Use `harnex wait --id ID` afterward to block until the session finishes.
26
+ TEXT
27
+ end
28
+
29
+ def initialize(argv)
30
+ @argv = argv.dup
31
+ @options = {
32
+ id: nil,
33
+ repo_path: Dir.pwd,
34
+ cli: nil,
35
+ timeout: DEFAULT_TIMEOUT,
36
+ help: false
37
+ }
38
+ end
39
+
40
+ def run
41
+ parser.parse!(@argv)
42
+ if @options[:help]
43
+ puts self.class.usage
44
+ return 0
45
+ end
46
+
47
+ raise "--id is required for harnex stop" unless @options[:id]
48
+
49
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
50
+ registry = Harnex.read_registry(repo_root, @options[:id], cli: @options[:cli])
51
+ unless registry
52
+ warn("harnex stop: no session found with id #{@options[:id].inspect}")
53
+ return 1
54
+ end
55
+
56
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/stop")
57
+ request = Net::HTTP::Post.new(uri)
58
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
59
+
60
+ deadline = monotonic_now + @options[:timeout]
61
+ response = with_http_retry(deadline: deadline) do
62
+ Net::HTTP.start(
63
+ uri.host,
64
+ uri.port,
65
+ open_timeout: http_timeout(deadline, cap: 1.0),
66
+ read_timeout: http_timeout(deadline, cap: 2.0)
67
+ ) { |http| http.request(request) }
68
+ end
69
+
70
+ parsed = parse_json_body(response.body)
71
+ puts JSON.generate(parsed)
72
+ response.is_a?(Net::HTTPSuccess) && parsed["error"].nil? ? 0 : 1
73
+ rescue TimeoutError => e
74
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", error: e.message)
75
+ 124
76
+ end
77
+
78
+ private
79
+
80
+ def with_http_retry(deadline:)
81
+ loop do
82
+ raise TimeoutError, timeout_message if deadline_reached?(deadline)
83
+
84
+ return yield
85
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, Net::ReadTimeout, Net::OpenTimeout
86
+ raise TimeoutError, timeout_message if deadline_reached?(deadline)
87
+
88
+ sleep 0.1
89
+ end
90
+ end
91
+
92
+ def monotonic_now
93
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
94
+ end
95
+
96
+ def deadline_reached?(deadline)
97
+ monotonic_now >= deadline
98
+ end
99
+
100
+ def http_timeout(deadline, cap: nil)
101
+ remaining = deadline - monotonic_now
102
+ remaining = [remaining, MIN_HTTP_TIMEOUT].max
103
+ cap ? [remaining, cap].min : remaining
104
+ end
105
+
106
+ def timeout_message
107
+ "request timed out after #{@options[:timeout]}s"
108
+ end
109
+
110
+ def parse_json_body(body)
111
+ JSON.parse(body.to_s.empty? ? "{}" : body.to_s)
112
+ rescue JSON::ParserError
113
+ { "ok" => false, "error" => "invalid json response", "raw_body" => body.to_s }
114
+ end
115
+
116
+ def parser
117
+ @parser ||= OptionParser.new do |opts|
118
+ opts.banner = "Usage: harnex stop [options]"
119
+ opts.on("--id ID", "Session ID to stop") { |value| @options[:id] = Harnex.normalize_id(value) }
120
+ opts.on("--repo PATH", "Resolve the session using PATH's repo root") { |value| @options[:repo_path] = value }
121
+ opts.on("--cli CLI", "Filter by CLI type") { |value| @options[:cli] = value }
122
+ opts.on("--timeout SECS", Float, "How long to retry transient API failures") { |value| @options[:timeout] = value }
123
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,165 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "optparse"
4
+ require "uri"
5
+
6
+ module Harnex
7
+ class Waiter
8
+ POLL_INTERVAL = 0.5
9
+
10
+ def self.usage(program_name = "harnex wait")
11
+ <<~TEXT
12
+ Usage: #{program_name} [options]
13
+
14
+ Options:
15
+ --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
+ --repo PATH Resolve session using PATH's repo root (default: current repo)
19
+ --timeout SECS Maximum time to wait in seconds (default: unlimited)
20
+ -h, --help Show this help
21
+ TEXT
22
+ end
23
+
24
+ def initialize(argv)
25
+ @argv = argv.dup
26
+ @options = {
27
+ id: nil,
28
+ until_state: nil,
29
+ repo_path: Dir.pwd,
30
+ timeout: nil,
31
+ help: false
32
+ }
33
+ end
34
+
35
+ def run
36
+ parser.parse!(@argv)
37
+ if @options[:help]
38
+ puts self.class.usage
39
+ return 0
40
+ end
41
+
42
+ raise "--id is required for harnex wait" unless @options[:id]
43
+
44
+ if @options[:until_state]
45
+ wait_until_state
46
+ else
47
+ wait_until_exit
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def wait_until_state
54
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
55
+ target_state = @options[:until_state]
56
+ start_time = Time.now
57
+ deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
58
+
59
+ registry = Harnex.read_registry(repo_root, @options[:id])
60
+ unless registry
61
+ warn("harnex wait: no session found with id #{@options[:id].inspect}")
62
+ return 1
63
+ end
64
+
65
+ target_pid = registry["pid"]
66
+ host = registry["host"]
67
+ port = registry["port"]
68
+ token = registry["token"]
69
+
70
+ warn("harnex wait: waiting for #{@options[:id]} to reach #{target_state}")
71
+
72
+ loop do
73
+ unless Harnex.alive_pid?(target_pid)
74
+ waited = (Time.now - start_time).round(1)
75
+ puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
76
+ return 1
77
+ end
78
+
79
+ state = fetch_agent_state(host, port, token)
80
+ if state == target_state
81
+ waited = (Time.now - start_time).round(1)
82
+ puts JSON.generate(ok: true, id: @options[:id], state: state, waited_seconds: waited)
83
+ return 0
84
+ end
85
+
86
+ if deadline && Time.now >= deadline
87
+ waited = (Time.now - start_time).round(1)
88
+ puts JSON.generate(ok: false, id: @options[:id], state: state || "unknown", waited_seconds: waited, status: "timeout")
89
+ return 124
90
+ end
91
+
92
+ sleep POLL_INTERVAL
93
+ end
94
+ end
95
+
96
+ def wait_until_exit
97
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
98
+ deadline = @options[:timeout] ? Time.now + @options[:timeout] : nil
99
+ exit_path = Harnex.exit_status_path(repo_root, @options[:id])
100
+
101
+ registry = Harnex.read_registry(repo_root, @options[:id])
102
+ unless registry
103
+ return read_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
104
+
105
+ warn("harnex wait: no session found with id #{@options[:id].inspect}")
106
+ return 1
107
+ end
108
+
109
+ target_pid = registry["pid"]
110
+ warn("harnex wait: watching session #{@options[:id]} (pid #{target_pid})")
111
+
112
+ loop do
113
+ unless Harnex.alive_pid?(target_pid)
114
+ return read_exit_status(exit_path, @options[:id])
115
+ end
116
+
117
+ if deadline && Time.now >= deadline
118
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", pid: target_pid)
119
+ return 124
120
+ end
121
+
122
+ sleep POLL_INTERVAL
123
+ end
124
+ end
125
+
126
+ def fetch_agent_state(host, port, token)
127
+ uri = URI("http://#{host}:#{port}/status")
128
+ request = Net::HTTP::Get.new(uri)
129
+ request["Authorization"] = "Bearer #{token}" if token
130
+
131
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
132
+ http.request(request)
133
+ end
134
+
135
+ return nil unless response.is_a?(Net::HTTPSuccess)
136
+
137
+ data = JSON.parse(response.body)
138
+ data["agent_state"]
139
+ rescue StandardError
140
+ nil
141
+ end
142
+
143
+ def read_exit_status(exit_path, id)
144
+ if File.exist?(exit_path)
145
+ data = JSON.parse(File.read(exit_path))
146
+ puts JSON.generate(data)
147
+ data["exit_code"] || 0
148
+ else
149
+ puts JSON.generate(ok: true, id: id, status: "exited")
150
+ 0
151
+ end
152
+ end
153
+
154
+ def parser
155
+ @parser ||= OptionParser.new do |opts|
156
+ opts.banner = "Usage: harnex wait [options]"
157
+ opts.on("--id ID", "Session ID to wait for") { |value| @options[:id] = Harnex.normalize_id(value) }
158
+ opts.on("--until STATE", "Wait until session reaches STATE") { |value| @options[:until_state] = value }
159
+ opts.on("--repo PATH", "Resolve session using PATH's repo root") { |value| @options[:repo_path] = value }
160
+ opts.on("--timeout SECONDS", Float, "Maximum time to wait") { |value| @options[:timeout] = value }
161
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
162
+ end
163
+ end
164
+ end
165
+ end