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,286 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "securerandom"
4
+ require "set"
5
+ require "socket"
6
+
7
+ module Harnex
8
+ class BinaryNotFound < RuntimeError; end
9
+
10
+ module_function
11
+
12
+ def env_value(name, default: nil)
13
+ ENV.fetch(name, default)
14
+ end
15
+
16
+ DEFAULT_HOST = env_value("HARNEX_HOST", default: "127.0.0.1")
17
+ DEFAULT_BASE_PORT = Integer(env_value("HARNEX_BASE_PORT", default: "43000"))
18
+ DEFAULT_PORT_SPAN = Integer(env_value("HARNEX_PORT_SPAN", default: "4000"))
19
+ DEFAULT_ID = "default"
20
+ WATCH_DEBOUNCE_SECONDS = 1.0
21
+ STATE_DIR = File.expand_path(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
22
+ SESSIONS_DIR = File.join(STATE_DIR, "sessions")
23
+ WatchConfig = Struct.new(:absolute_path, :display_path, :hook_message, :debounce_seconds, keyword_init: true)
24
+ ID_ADJECTIVES = %w[
25
+ bold blue calm cool dark dry fast gold gray green
26
+ keen loud mint pale pink red shy slim soft warm
27
+ ].freeze
28
+ ID_NOUNS = %w[
29
+ ant bat bee cat cod cow cub doe elk fox
30
+ hen jay kit owl pug ram ray seal wasp yak
31
+ ].freeze
32
+
33
+ def resolve_repo_root(path = Dir.pwd)
34
+ output, status = Open3.capture2("git", "rev-parse", "--show-toplevel", chdir: path)
35
+ status.success? ? output.strip : File.expand_path(path)
36
+ rescue StandardError
37
+ File.expand_path(path)
38
+ end
39
+
40
+ def repo_key(repo_root)
41
+ Digest::SHA256.hexdigest(repo_root)[0, 16]
42
+ end
43
+
44
+ def normalize_id(id)
45
+ value = id.to_s.strip
46
+ raise "id is required" if value.empty?
47
+
48
+ value
49
+ end
50
+
51
+ def id_key(id)
52
+ normalize_id(id).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
53
+ end
54
+
55
+ def cli_key(cli)
56
+ value = cli.to_s.strip.downcase
57
+ return nil if value.empty?
58
+
59
+ value.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
60
+ end
61
+
62
+ def current_session_context(env = ENV)
63
+ session_id = env["HARNEX_SESSION_ID"].to_s.strip
64
+ cli = env["HARNEX_SESSION_CLI"].to_s.strip
65
+ id = env["HARNEX_ID"].to_s.strip
66
+ repo_root = env["HARNEX_SESSION_REPO_ROOT"].to_s.strip
67
+ return nil if session_id.empty? || cli.empty? || id.empty?
68
+
69
+ {
70
+ session_id: session_id,
71
+ cli: cli,
72
+ id: id,
73
+ repo_root: repo_root.empty? ? nil : repo_root
74
+ }
75
+ end
76
+
77
+ def format_relay_message(text, from:, id:, at: Time.now)
78
+ header = "[harnex relay from=#{from} id=#{normalize_id(id)} at=#{at.iso8601}]"
79
+ body = text.to_s
80
+ return header if body.empty?
81
+
82
+ "#{header}\n#{body}"
83
+ end
84
+
85
+ def active_session_ids(repo_root)
86
+ active_sessions(repo_root).map { |session| session["id"].to_s.downcase }.to_set
87
+ end
88
+
89
+ def generate_id(repo_root)
90
+ taken = active_session_ids(repo_root)
91
+ ID_ADJECTIVES.product(ID_NOUNS).shuffle.each do |adj, noun|
92
+ candidate = "#{adj}-#{noun}"
93
+ return candidate unless taken.include?(candidate)
94
+ end
95
+
96
+ "session-#{SecureRandom.hex(4)}"
97
+ end
98
+
99
+ def registry_path(repo_root, id = DEFAULT_ID)
100
+ FileUtils.mkdir_p(SESSIONS_DIR)
101
+ File.join(SESSIONS_DIR, "#{session_file_slug(repo_root, id)}.json")
102
+ end
103
+
104
+ def exit_status_path(repo_root, id)
105
+ exit_dir = File.join(STATE_DIR, "exits")
106
+ FileUtils.mkdir_p(exit_dir)
107
+ File.join(exit_dir, "#{session_file_slug(repo_root, id)}.json")
108
+ end
109
+
110
+ def output_log_path(repo_root, id)
111
+ output_dir = File.join(STATE_DIR, "output")
112
+ FileUtils.mkdir_p(output_dir)
113
+ File.join(output_dir, "#{session_file_slug(repo_root, id)}.log")
114
+ end
115
+
116
+ def session_file_slug(repo_root, id)
117
+ slug = id_key(id)
118
+ slug = "default" if slug.empty?
119
+ "#{repo_key(repo_root)}--#{slug}"
120
+ end
121
+
122
+ def active_sessions(repo_root = nil, id: nil, cli: nil)
123
+ FileUtils.mkdir_p(SESSIONS_DIR)
124
+ pattern =
125
+ if repo_root
126
+ File.join(SESSIONS_DIR, "#{repo_key(repo_root)}--*.json")
127
+ else
128
+ File.join(SESSIONS_DIR, "*.json")
129
+ end
130
+
131
+ target_id_key = id.nil? ? nil : id_key(id)
132
+ normalized_cli = cli_key(cli)
133
+
134
+ Dir.glob(pattern).sort.filter_map do |path|
135
+ data = JSON.parse(File.read(path))
136
+ if data["pid"] && alive_pid?(data["pid"])
137
+ session = data.merge("registry_path" => path)
138
+ next if target_id_key && id_key(session["id"].to_s) != target_id_key
139
+ next if normalized_cli && cli_key(session_cli(session)) != normalized_cli
140
+
141
+ session
142
+ else
143
+ FileUtils.rm_f(path)
144
+ nil
145
+ end
146
+ rescue JSON::ParserError
147
+ FileUtils.rm_f(path)
148
+ nil
149
+ end
150
+ end
151
+
152
+ def alive_pid?(pid)
153
+ Process.kill(0, Integer(pid))
154
+ true
155
+ rescue Errno::ESRCH
156
+ false
157
+ rescue Errno::EPERM
158
+ true
159
+ end
160
+
161
+ def read_registry(repo_root, id = DEFAULT_ID, cli: nil)
162
+ sessions = active_sessions(repo_root, id: id, cli: cli)
163
+ return nil unless sessions.length == 1
164
+
165
+ sessions.first
166
+ end
167
+
168
+ def tmux_pane_for_pid(pid)
169
+ target_pid = Integer(pid)
170
+ stdout, status = Open3.capture2(
171
+ "tmux", "list-panes", "-a", "-F",
172
+ "\#{pane_id}\t\#{pane_pid}\t\#{session_name}\t\#{window_name}"
173
+ )
174
+ return nil unless status.success?
175
+
176
+ panes = stdout.each_line.filter_map do |line|
177
+ pane_id, pane_pid, session_name, window_name = line.chomp.split("\t", 4)
178
+ next if pane_id.to_s.empty?
179
+
180
+ {
181
+ target: pane_id,
182
+ pane_id: pane_id,
183
+ pane_pid: pane_pid.to_i,
184
+ session_name: session_name,
185
+ window_name: window_name
186
+ }
187
+ end
188
+
189
+ pane_pids = panes.map { |p| p[:pane_pid] }.to_set
190
+
191
+ # Direct match first
192
+ matches = panes.select { |p| p[:pane_pid] == target_pid }
193
+
194
+ # If no direct match, walk up the process tree from target_pid
195
+ # to find an ancestor that is a tmux pane root process.
196
+ if matches.empty?
197
+ ancestor = parent_pid(target_pid)
198
+ while ancestor && ancestor > 1
199
+ if pane_pids.include?(ancestor)
200
+ matches = panes.select { |p| p[:pane_pid] == ancestor }
201
+ break
202
+ end
203
+ ancestor = parent_pid(ancestor)
204
+ end
205
+ end
206
+
207
+ return nil unless matches.length == 1
208
+
209
+ result = matches.first
210
+ result.delete(:pane_pid)
211
+ result
212
+ rescue ArgumentError, Errno::ENOENT
213
+ nil
214
+ end
215
+
216
+ def parent_pid(pid)
217
+ stat = File.read("/proc/#{pid}/stat")
218
+ # Field 4 is ppid (fields are space-separated, field 1 is pid,
219
+ # field 2 is (comm) which may contain spaces, field 3 is state, field 4 is ppid)
220
+ parts = stat.match(/\A\d+\s+\(.*?\)\s+\S+\s+(\d+)/)
221
+ parts ? parts[1].to_i : nil
222
+ rescue Errno::ENOENT, Errno::EACCES
223
+ nil
224
+ end
225
+
226
+ def write_registry(path, payload)
227
+ tmp = "#{path}.tmp.#{Process.pid}"
228
+ File.write(tmp, JSON.pretty_generate(payload))
229
+ File.rename(tmp, path)
230
+ end
231
+
232
+ def allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST)
233
+ if requested_port
234
+ return requested_port if port_available?(host, requested_port)
235
+
236
+ raise "port #{requested_port} is already in use on #{host}"
237
+ end
238
+
239
+ seed = Digest::SHA256.hexdigest("#{repo_root}\0#{normalize_id(id)}").to_i(16)
240
+ offset = seed % DEFAULT_PORT_SPAN
241
+
242
+ DEFAULT_PORT_SPAN.times do |index|
243
+ port = DEFAULT_BASE_PORT + ((offset + index) % DEFAULT_PORT_SPAN)
244
+ return port if port_available?(host, port)
245
+ end
246
+
247
+ raise "could not find a free port in #{DEFAULT_BASE_PORT}-#{DEFAULT_BASE_PORT + DEFAULT_PORT_SPAN - 1}"
248
+ end
249
+
250
+ def port_available?(host, port)
251
+ server = TCPServer.new(host, port)
252
+ server.close
253
+ true
254
+ rescue Errno::EADDRINUSE, Errno::EACCES
255
+ false
256
+ end
257
+
258
+ def build_adapter(cli, argv)
259
+ raise ArgumentError, "cli is required" if cli.to_s.strip.empty?
260
+
261
+ Adapters.build(cli, argv)
262
+ end
263
+
264
+ def session_cli(session)
265
+ (session["cli"] || Array(session["command"]).first).to_s
266
+ end
267
+
268
+ def build_watch_config(path, repo_root)
269
+ return nil if path.nil?
270
+
271
+ raise "file watch is unsupported on this system" unless Watcher.available?
272
+
273
+ display_path = path.to_s.strip
274
+ raise ArgumentError, "--watch requires a value" if display_path.empty?
275
+
276
+ absolute_path = File.expand_path(display_path, repo_root)
277
+ FileUtils.mkdir_p(File.dirname(absolute_path))
278
+
279
+ WatchConfig.new(
280
+ absolute_path: absolute_path,
281
+ display_path: display_path,
282
+ hook_message: "file-change-hook: read #{display_path}",
283
+ debounce_seconds: WATCH_DEBOUNCE_SECONDS
284
+ )
285
+ end
286
+ end
@@ -0,0 +1,187 @@
1
+ require "json"
2
+ require "socket"
3
+
4
+ module Harnex
5
+ class ApiServer
6
+ def initialize(session)
7
+ @session = session
8
+ @server = TCPServer.new(session.host, session.port)
9
+ @server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
10
+ @thread = nil
11
+ end
12
+
13
+ def start
14
+ @thread = Thread.new do
15
+ loop do
16
+ socket = @server.accept
17
+ Thread.new(socket) { |client| handle(client) }
18
+ rescue IOError, Errno::EBADF
19
+ break
20
+ end
21
+ end
22
+ end
23
+
24
+ def stop
25
+ @server.close
26
+ @thread&.join(1)
27
+ rescue IOError, Errno::EBADF
28
+ nil
29
+ end
30
+
31
+ private
32
+
33
+ def handle(client)
34
+ request_line = client.gets("\r\n")
35
+ return unless request_line
36
+
37
+ method, target, = request_line.split(" ", 3)
38
+ headers = {}
39
+ while (line = client.gets("\r\n"))
40
+ line = line.strip
41
+ break if line.empty?
42
+
43
+ key, value = line.split(":", 2)
44
+ headers[key.downcase] = value.to_s.strip
45
+ end
46
+
47
+ body = +""
48
+ length = headers.fetch("content-length", "0").to_i
49
+ body = client.read(length) if length.positive?
50
+
51
+ path = target.to_s.split("?", 2).first
52
+
53
+ case [method, path]
54
+ when ["GET", "/health"], ["GET", "/status"]
55
+ return unauthorized(client) unless authorized?(headers)
56
+
57
+ json(client, 200, @session.status_payload)
58
+ when ["GET", "/inbox"]
59
+ return unauthorized(client) unless authorized?(headers)
60
+
61
+ json(client, 200, ok: true, messages: @session.inbox.pending_messages)
62
+ when ["DELETE", "/inbox"]
63
+ return unauthorized(client) unless authorized?(headers)
64
+
65
+ json(client, 200, ok: true, cleared: @session.inbox.clear)
66
+ when ["POST", "/stop"]
67
+ return unauthorized(client) unless authorized?(headers)
68
+
69
+ json(client, 200, @session.inject_stop)
70
+ when ["POST", "/send"]
71
+ return unauthorized(client) unless authorized?(headers)
72
+
73
+ payload = parse_send_body(headers, body)
74
+ if payload[:mode] == :adapter
75
+ return json(client, 400, ok: false, error: "text is required") if payload[:text].to_s.empty? && !payload[:enter_only]
76
+
77
+ result = @session.inbox.enqueue(
78
+ text: payload[:text],
79
+ submit: payload[:submit],
80
+ enter_only: payload[:enter_only],
81
+ force: payload[:force]
82
+ )
83
+ http_code = result.delete(:http_status) || 200
84
+ json(client, http_code, result)
85
+ else
86
+ return json(client, 400, ok: false, error: "text is required") if payload[:text].to_s.empty?
87
+
88
+ json(client, 200, @session.inject(payload[:text], newline: payload[:newline]))
89
+ end
90
+ else
91
+ if method == "GET" && path =~ %r{\A/messages/([a-f0-9]+)\z}
92
+ return unauthorized(client) unless authorized?(headers)
93
+
94
+ msg_id = Regexp.last_match(1)
95
+ msg = @session.inbox.message_status(msg_id)
96
+ if msg
97
+ json(client, 200, msg)
98
+ else
99
+ json(client, 404, ok: false, error: "message not found")
100
+ end
101
+ elsif method == "DELETE" && path =~ %r{\A/inbox/([a-f0-9]+)\z}
102
+ return unauthorized(client) unless authorized?(headers)
103
+
104
+ msg_id = Regexp.last_match(1)
105
+ msg = @session.inbox.drop(msg_id)
106
+ if msg
107
+ json(client, 200, ok: true, message: msg)
108
+ else
109
+ json(client, 404, ok: false, error: "message not found")
110
+ end
111
+ else
112
+ json(client, 404, ok: false, error: "not found")
113
+ end
114
+ end
115
+ rescue JSON::ParserError
116
+ json(client, 400, ok: false, error: "invalid json")
117
+ rescue ArgumentError => e
118
+ json(client, 409, ok: false, error: e.message)
119
+ rescue StandardError => e
120
+ if e.message.start_with?("inbox full")
121
+ json(client, 503, ok: false, error: e.message)
122
+ else
123
+ json(client, 500, ok: false, error: e.message)
124
+ end
125
+ ensure
126
+ client.close unless client.closed?
127
+ end
128
+
129
+ def parse_send_body(headers, body)
130
+ if headers["content-type"].to_s.include?("application/json")
131
+ parsed = JSON.parse(body.empty? ? "{}" : body)
132
+ if parsed.key?("submit") || parsed.key?("enter_only") || parsed.key?("force")
133
+ {
134
+ mode: :adapter,
135
+ text: parsed["text"].to_s,
136
+ submit: parsed.fetch("submit", true),
137
+ enter_only: parsed.fetch("enter_only", false),
138
+ force: parsed.fetch("force", false)
139
+ }
140
+ else
141
+ {
142
+ mode: :legacy,
143
+ text: parsed["text"].to_s,
144
+ newline: parsed.fetch("newline", true)
145
+ }
146
+ end
147
+ else
148
+ {
149
+ mode: :legacy,
150
+ text: body.to_s,
151
+ newline: true
152
+ }
153
+ end
154
+ end
155
+
156
+ def authorized?(headers)
157
+ @session.auth_ok?(headers["authorization"].to_s)
158
+ end
159
+
160
+ def unauthorized(client)
161
+ json(client, 401, ok: false, error: "unauthorized")
162
+ end
163
+
164
+ def json(client, code, payload)
165
+ body = JSON.generate(payload)
166
+ client.write("HTTP/1.1 #{code} #{http_reason(code)}\r\n")
167
+ client.write("Content-Type: application/json\r\n")
168
+ client.write("Content-Length: #{body.bytesize}\r\n")
169
+ client.write("Connection: close\r\n")
170
+ client.write("\r\n")
171
+ client.write(body)
172
+ end
173
+
174
+ def http_reason(code)
175
+ {
176
+ 200 => "OK",
177
+ 202 => "Accepted",
178
+ 400 => "Bad Request",
179
+ 401 => "Unauthorized",
180
+ 409 => "Conflict",
181
+ 503 => "Service Unavailable",
182
+ 404 => "Not Found",
183
+ 500 => "Internal Server Error"
184
+ }.fetch(code, "OK")
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,111 @@
1
+ module Harnex
2
+ class FileChangeHook
3
+ EVENT_HEADER_SIZE = 16
4
+ WATCH_MASK = Inotify::IN_ATTRIB | Inotify::IN_CLOSE_WRITE | Inotify::IN_CREATE | Inotify::IN_MOVED_TO
5
+ RETRY_SECONDS = 1.0
6
+ IDLE_SLEEP_SECONDS = 0.1
7
+
8
+ def initialize(session, config)
9
+ @session = session
10
+ @config = config
11
+ @target_dir = File.dirname(config.absolute_path)
12
+ @target_name = File.basename(config.absolute_path)
13
+ @buffer = +""
14
+ @buffer.force_encoding(Encoding::BINARY)
15
+ @mutex = Mutex.new
16
+ @change_generation = 0
17
+ @delivered_generation = 0
18
+ @last_change_at = nil
19
+ end
20
+
21
+ def start
22
+ Thread.new { run }
23
+ end
24
+
25
+ private
26
+
27
+ def run
28
+ reader_thread = Thread.new { watch_loop }
29
+ delivery_loop
30
+ ensure
31
+ reader_thread&.kill
32
+ reader_thread&.join(0.1)
33
+ end
34
+
35
+ def watch_loop
36
+ io = Watcher.directory_io(@target_dir, WATCH_MASK)
37
+ loop do
38
+ chunk = io.readpartial(4096)
39
+ note_change if relevant_change?(chunk)
40
+ rescue EOFError, IOError, Errno::EIO
41
+ break
42
+ end
43
+ ensure
44
+ io&.close unless io&.closed?
45
+ end
46
+
47
+ def delivery_loop
48
+ loop do
49
+ generation, delivered_generation, last_change_at = snapshot
50
+ if generation == delivered_generation || last_change_at.nil?
51
+ sleep IDLE_SLEEP_SECONDS
52
+ next
53
+ end
54
+
55
+ remaining = @config.debounce_seconds - (Time.now - last_change_at)
56
+ if remaining.positive?
57
+ sleep [remaining, IDLE_SLEEP_SECONDS].max
58
+ next
59
+ end
60
+
61
+ begin
62
+ @session.inbox.enqueue(
63
+ text: @config.hook_message,
64
+ submit: true,
65
+ enter_only: false,
66
+ force: false
67
+ )
68
+ mark_delivered
69
+ rescue StandardError => e
70
+ break if e.message == "session is not running"
71
+
72
+ sleep RETRY_SECONDS
73
+ end
74
+ end
75
+ end
76
+
77
+ def relevant_change?(chunk)
78
+ @buffer << chunk
79
+ changed = false
80
+
81
+ while @buffer.bytesize >= EVENT_HEADER_SIZE
82
+ _, mask, _, name_length = @buffer.byteslice(0, EVENT_HEADER_SIZE).unpack("iIII")
83
+ event_size = EVENT_HEADER_SIZE + name_length
84
+ break if @buffer.bytesize < event_size
85
+
86
+ name = @buffer.byteslice(EVENT_HEADER_SIZE, name_length).to_s.delete("\0")
87
+ changed ||= name == @target_name && (mask & WATCH_MASK).positive?
88
+ @buffer = @buffer.byteslice(event_size, @buffer.bytesize - event_size).to_s
89
+ end
90
+
91
+ changed
92
+ end
93
+
94
+ def note_change
95
+ @mutex.synchronize do
96
+ @change_generation += 1
97
+ @last_change_at = Time.now
98
+ end
99
+ end
100
+
101
+ def snapshot
102
+ @mutex.synchronize { [@change_generation, @delivered_generation, @last_change_at] }
103
+ end
104
+
105
+ def mark_delivered
106
+ @mutex.synchronize do
107
+ @delivered_generation = @change_generation
108
+ end
109
+ end
110
+ end
111
+ end