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,207 @@
1
+ require "securerandom"
2
+
3
+ module Harnex
4
+ class Inbox
5
+ DEFAULT_TTL = 120
6
+ MAX_PENDING = 64
7
+ DELIVERY_TIMEOUT = 300
8
+
9
+ def initialize(session, state_machine, ttl: DEFAULT_TTL)
10
+ @session = session
11
+ @state_machine = state_machine
12
+ @ttl = ttl.to_f
13
+ @queue = []
14
+ @messages = {}
15
+ @mutex = Mutex.new
16
+ @condvar = ConditionVariable.new
17
+ @thread = nil
18
+ @running = false
19
+ @delivered_total = 0
20
+ @expired_total = 0
21
+ end
22
+
23
+ def start
24
+ @running = true
25
+ @thread = Thread.new { delivery_loop }
26
+ end
27
+
28
+ def stop
29
+ @running = false
30
+ @mutex.synchronize { @condvar.broadcast }
31
+ @thread&.join(2)
32
+ @thread&.kill
33
+ end
34
+
35
+ def enqueue(text:, submit:, enter_only:, force: false)
36
+ msg = Message.new(
37
+ id: SecureRandom.hex(8),
38
+ text: text,
39
+ submit: submit,
40
+ enter_only: enter_only,
41
+ force: force,
42
+ queued_at: Time.now,
43
+ status: :queued
44
+ )
45
+
46
+ # Force messages bypass the queue entirely
47
+ if force
48
+ return deliver_now(msg)
49
+ end
50
+
51
+ # Fast path: prompt ready and queue empty — deliver immediately.
52
+ # Check under lock, then release before calling deliver_now to
53
+ # avoid recursive locking (deliver_now also acquires @mutex).
54
+ try_fast = @mutex.synchronize do
55
+ @queue.empty? && @state_machine.state == :prompt
56
+ end
57
+
58
+ if try_fast
59
+ begin
60
+ result = deliver_now(msg)
61
+ return result if msg.status == :delivered
62
+ rescue StandardError
63
+ # Fall through to queue if delivery failed
64
+ end
65
+ msg.status = :queued
66
+ msg.error = nil
67
+ end
68
+
69
+ @mutex.synchronize do
70
+ raise "inbox full (#{MAX_PENDING} pending messages)" if @queue.size >= MAX_PENDING
71
+
72
+ @queue << msg
73
+ @messages[msg.id] = msg
74
+ @condvar.broadcast
75
+ end
76
+
77
+ { ok: true, status: "queued", message_id: msg.id, http_status: 202 }
78
+ end
79
+
80
+ def message_status(id)
81
+ @mutex.synchronize do
82
+ msg = @messages[id]
83
+ return nil unless msg
84
+ msg.to_h
85
+ end
86
+ end
87
+
88
+ def stats
89
+ @mutex.synchronize do
90
+ { pending: @queue.size, delivered_total: @delivered_total, expired_total: @expired_total }
91
+ end
92
+ end
93
+
94
+ def pending_messages
95
+ @mutex.synchronize { @queue.map(&:to_h) }
96
+ end
97
+
98
+ def drop(message_id)
99
+ @mutex.synchronize do
100
+ msg = @messages[message_id]
101
+ return nil unless msg && @queue.any? { |queued| queued.id == message_id }
102
+
103
+ @queue.delete_if { |queued| queued.id == message_id }
104
+ msg.status = :dropped
105
+ msg.to_h
106
+ end
107
+ end
108
+
109
+ def clear
110
+ @mutex.synchronize do
111
+ count = @queue.size
112
+ @queue.each { |msg| msg.status = :dropped }
113
+ @queue.clear
114
+ count
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def deliver_now(msg)
121
+ result = @session.inject_via_adapter(
122
+ text: msg.text,
123
+ submit: msg.submit,
124
+ enter_only: msg.enter_only,
125
+ force: msg.force
126
+ )
127
+ msg.status = :delivered
128
+ msg.delivered_at = Time.now
129
+ @mutex.synchronize do
130
+ @delivered_total += 1
131
+ @messages[msg.id] = msg
132
+ end
133
+ result.merge(ok: true, status: "delivered", message_id: msg.id, http_status: 200)
134
+ rescue ArgumentError => e
135
+ msg.status = :failed
136
+ msg.error = e.message
137
+ @mutex.synchronize { @messages[msg.id] = msg }
138
+ raise
139
+ end
140
+
141
+ def delivery_loop
142
+ while @running
143
+ msg = @mutex.synchronize do
144
+ expire_stale_messages_locked
145
+ while @queue.empty? && @running
146
+ @condvar.wait(@mutex, 1.0)
147
+ expire_stale_messages_locked
148
+ end
149
+ @queue.first
150
+ end
151
+
152
+ break unless @running
153
+ next unless msg
154
+
155
+ ready = @state_machine.wait_for_prompt(prompt_wait_timeout)
156
+ unless ready
157
+ next if @running # Keep waiting
158
+ end
159
+
160
+ msg = @mutex.synchronize do
161
+ expire_stale_messages_locked
162
+ @queue.first
163
+ end
164
+ next unless msg
165
+
166
+ begin
167
+ deliver_now(msg)
168
+ @mutex.synchronize { @queue.shift if @queue.first&.id == msg.id }
169
+ rescue ArgumentError
170
+ # State race — will retry on next loop iteration
171
+ sleep 0.1
172
+ rescue StandardError => e
173
+ msg.status = :failed
174
+ msg.error = e.message
175
+ @mutex.synchronize do
176
+ @queue.shift
177
+ @messages[msg.id] = msg
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ def expire_stale_messages
184
+ @mutex.synchronize { expire_stale_messages_locked }
185
+ end
186
+
187
+ def expire_stale_messages_locked(now = Time.now)
188
+ while (msg = @queue.first) && stale_message?(msg, now)
189
+ msg.status = :expired
190
+ @queue.shift
191
+ @expired_total += 1
192
+ end
193
+ end
194
+
195
+ def stale_message?(msg, now)
196
+ return false unless msg.queued_at
197
+
198
+ (now - msg.queued_at) > @ttl
199
+ end
200
+
201
+ def prompt_wait_timeout
202
+ return 0.1 if @ttl <= 0.0
203
+
204
+ [DELIVERY_TIMEOUT.to_f, @ttl].min
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,23 @@
1
+ module Harnex
2
+ Message = Struct.new(:id, :text, :submit, :enter_only, :force, :queued_at, :status, :delivered_at, :error, keyword_init: true) do
3
+ def to_h
4
+ {
5
+ id: id,
6
+ status: status.to_s,
7
+ queued_at: queued_at&.iso8601,
8
+ delivered_at: delivered_at&.iso8601,
9
+ text_preview: preview_text,
10
+ error: error
11
+ }
12
+ end
13
+
14
+ private
15
+
16
+ def preview_text(limit = 80)
17
+ compact = text.to_s.gsub(/\s+/, " ").strip
18
+ return compact if compact.length <= limit
19
+
20
+ "#{compact[0, limit - 3]}..."
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,380 @@
1
+ require "io/console"
2
+ require "json"
3
+ require "pty"
4
+
5
+ module Harnex
6
+ class Session
7
+ OUTPUT_BUFFER_LIMIT = 64 * 1024
8
+
9
+ attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path
10
+
11
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, inbox_ttl: Inbox::DEFAULT_TTL)
12
+ @adapter = adapter
13
+ @command = command
14
+ @repo_root = repo_root
15
+ @host = host
16
+ @id = Harnex.normalize_id(id)
17
+ @watch = watch
18
+ @description = description.to_s.strip
19
+ @description = nil if @description.empty?
20
+ @registry_path = Harnex.registry_path(repo_root, @id)
21
+ @output_log_path = Harnex.output_log_path(repo_root, @id)
22
+ @session_id = SecureRandom.hex(8)
23
+ @token = SecureRandom.hex(16)
24
+ @port = Harnex.allocate_port(repo_root, @id, port, host: host)
25
+ @mutex = Mutex.new
26
+ @inject_mutex = Mutex.new
27
+ @injected_count = 0
28
+ @last_injected_at = nil
29
+ @started_at = Time.now
30
+ @server = nil
31
+ @reader = nil
32
+ @output_log = nil
33
+ @writer = nil
34
+ @pid = nil
35
+ @term_signal = nil
36
+ @output_buffer = +""
37
+ @output_buffer.force_encoding(Encoding::BINARY)
38
+ @state_machine = SessionState.new(adapter)
39
+ @inbox = Inbox.new(self, @state_machine, ttl: inbox_ttl)
40
+ end
41
+
42
+ def self.validate_binary!(command)
43
+ binary = Array(command).first.to_s
44
+ raise BinaryNotFound, "\"\" not found — is it installed and on your PATH?" if binary.empty?
45
+
46
+ if binary.include?("/")
47
+ return binary if File.executable?(binary) && !File.directory?(binary)
48
+
49
+ raise BinaryNotFound, "\"#{binary}\" not found — is it installed and on your PATH?"
50
+ end
51
+
52
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
53
+ path = File.join(dir, binary)
54
+ return path if File.executable?(path) && !File.directory?(path)
55
+ end
56
+
57
+ raise BinaryNotFound, "\"#{binary}\" not found — is it installed and on your PATH?"
58
+ end
59
+
60
+ def run(validate_binary: true)
61
+ validate_binary! if validate_binary
62
+ prepare_output_log
63
+ @reader, @writer, @pid = PTY.spawn(child_env, *command)
64
+ @writer.sync = true
65
+
66
+ install_signal_handlers
67
+ sync_window_size
68
+ @server = ApiServer.new(self)
69
+ @server.start
70
+ persist_registry
71
+
72
+ stdin_state = STDIN.tty? ? STDIN.raw! : nil
73
+ watch_thread = start_watch_thread
74
+ @inbox.start
75
+ input_thread = start_input_thread
76
+ output_thread = start_output_thread
77
+
78
+ _, status = Process.wait2(pid)
79
+ @term_signal = status.signaled? ? status.termsig : nil
80
+ @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
81
+
82
+ output_thread.join(1)
83
+ input_thread&.kill
84
+ watch_thread&.kill
85
+ @exit_code
86
+ ensure
87
+ @inbox.stop
88
+ STDIN.cooked! if STDIN.tty? && stdin_state
89
+ @server&.stop
90
+ persist_exit_status
91
+ cleanup_registry
92
+ @reader&.close unless @reader&.closed?
93
+ @output_log&.close unless @output_log&.closed?
94
+ @writer&.close unless @writer&.closed?
95
+ end
96
+
97
+ def status_payload(include_input_state: true)
98
+ payload = {
99
+ ok: true,
100
+ session_id: session_id,
101
+ repo_root: repo_root,
102
+ repo_key: Harnex.repo_key(repo_root),
103
+ cli: adapter.key,
104
+ id: id,
105
+ pid: pid,
106
+ host: host,
107
+ port: port,
108
+ command: command,
109
+ started_at: @started_at.iso8601,
110
+ last_injected_at: @last_injected_at&.iso8601,
111
+ injected_count: @injected_count,
112
+ output_log_path: output_log_path
113
+ }
114
+ payload[:description] = description if description
115
+
116
+ if watch
117
+ payload[:watch_path] = watch.display_path
118
+ payload[:watch_absolute_path] = watch.absolute_path
119
+ payload[:watch_debounce_seconds] = watch.debounce_seconds
120
+ end
121
+
122
+ payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
123
+ payload[:agent_state] = @state_machine.to_s
124
+ payload[:inbox] = @inbox.stats
125
+ payload
126
+ end
127
+
128
+ def auth_ok?(header)
129
+ header == "Bearer #{token}"
130
+ end
131
+
132
+ def inject(text, newline: true)
133
+ raise "session is not running" unless pid && Harnex.alive_pid?(pid)
134
+
135
+ inject_sequence([{ text: text, newline: newline }])
136
+ end
137
+
138
+ def inject_stop
139
+ raise "session is not running" unless pid && Harnex.alive_pid?(pid)
140
+
141
+ @inject_mutex.synchronize do
142
+ adapter.inject_exit(@writer)
143
+ @state_machine.force_busy!
144
+ end
145
+
146
+ { ok: true, signal: "exit_sequence_sent" }
147
+ end
148
+
149
+ def inject_via_adapter(text:, submit:, enter_only:, force: false)
150
+ snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
151
+ payload = adapter.build_send_payload(
152
+ text: text,
153
+ submit: submit,
154
+ enter_only: enter_only,
155
+ screen_text: snapshot,
156
+ force: force
157
+ )
158
+
159
+ result =
160
+ if payload[:steps]
161
+ inject_sequence(payload.fetch(:steps))
162
+ else
163
+ inject(payload.fetch(:text), newline: payload.fetch(:newline, false))
164
+ end
165
+
166
+ result.merge(
167
+ cli: adapter.key,
168
+ input_state: payload[:input_state],
169
+ force: payload[:force]
170
+ )
171
+ end
172
+
173
+ def sync_window_size
174
+ return unless STDIN.tty?
175
+
176
+ @writer.winsize = STDIN.winsize
177
+ rescue StandardError
178
+ nil
179
+ end
180
+
181
+ def validate_binary!
182
+ self.class.validate_binary!(command)
183
+ end
184
+
185
+ private
186
+
187
+ def child_env
188
+ env = {
189
+ "HARNEX_SESSION_ID" => session_id,
190
+ "HARNEX_SESSION_CLI" => adapter.key,
191
+ "HARNEX_ID" => id,
192
+ "HARNEX_SESSION_REPO_ROOT" => repo_root
193
+ }
194
+ env["HARNEX_DESCRIPTION"] = description if description
195
+ env
196
+ end
197
+
198
+ def inject_sequence(steps)
199
+ @inject_mutex.synchronize do
200
+ total_bytes = 0
201
+ newline = false
202
+
203
+ steps.each do |step|
204
+ delay_ms = step[:delay_ms].to_i
205
+ sleep(delay_ms / 1000.0) if delay_ms.positive?
206
+
207
+ payload = step.fetch(:text, "").dup
208
+ newline = step.fetch(:newline, false)
209
+ payload << "\n" if newline
210
+ total_bytes += write_payload(payload)
211
+ end
212
+
213
+ result = finish_injection(bytes_written: total_bytes, newline: newline)
214
+ @state_machine.force_busy!
215
+ result
216
+ end
217
+ end
218
+
219
+ def write_payload(payload)
220
+ @mutex.synchronize do
221
+ bytes = @writer.write(payload)
222
+ @writer.flush
223
+ bytes
224
+ end
225
+ end
226
+
227
+ def finish_injection(bytes_written:, newline:)
228
+ injected_count = @mutex.synchronize do
229
+ @injected_count += 1
230
+ @last_injected_at = Time.now
231
+ persist_registry
232
+ @injected_count
233
+ end
234
+
235
+ {
236
+ ok: true,
237
+ bytes_written: bytes_written,
238
+ injected_count: injected_count,
239
+ newline: newline
240
+ }
241
+ end
242
+
243
+ def registry_payload
244
+ status_payload(include_input_state: false).merge(
245
+ token: token,
246
+ cwd: Dir.pwd
247
+ )
248
+ end
249
+
250
+ def persist_registry
251
+ payload = registry_payload
252
+ preserved = load_existing_registry_metadata
253
+ payload = payload.merge(preserved) unless preserved.empty?
254
+ Harnex.write_registry(@registry_path, payload)
255
+ end
256
+
257
+ def persist_exit_status
258
+ return unless defined?(@exit_code) && !@exit_code.nil?
259
+
260
+ exit_path = Harnex.exit_status_path(repo_root, id)
261
+ payload = {
262
+ ok: true,
263
+ id: id,
264
+ cli: adapter.key,
265
+ session_id: session_id,
266
+ repo_root: repo_root,
267
+ exit_code: @exit_code,
268
+ started_at: @started_at.iso8601,
269
+ exited_at: Time.now.iso8601,
270
+ injected_count: @injected_count
271
+ }
272
+ payload[:signal] = @term_signal if @term_signal
273
+ Harnex.write_registry(exit_path, payload)
274
+ rescue StandardError
275
+ nil
276
+ end
277
+
278
+ def cleanup_registry
279
+ current = File.exist?(@registry_path) ? JSON.parse(File.read(@registry_path)) : nil
280
+ return unless current && current["session_id"] == session_id
281
+
282
+ FileUtils.rm_f(@registry_path)
283
+ rescue JSON::ParserError
284
+ nil
285
+ end
286
+
287
+ def start_input_thread
288
+ Thread.new do
289
+ loop do
290
+ chunk = STDIN.readpartial(4096)
291
+ @inject_mutex.synchronize do
292
+ @mutex.synchronize do
293
+ @writer.write(chunk)
294
+ @writer.flush
295
+ end
296
+ end
297
+ rescue EOFError, Errno::EIO, IOError
298
+ break
299
+ end
300
+ end
301
+ end
302
+
303
+ def start_output_thread
304
+ Thread.new do
305
+ loop do
306
+ chunk = @reader.readpartial(4096)
307
+ record_output(chunk)
308
+ STDOUT.write(chunk)
309
+ STDOUT.flush
310
+ rescue EOFError, Errno::EIO, IOError
311
+ break
312
+ end
313
+ end
314
+ end
315
+
316
+ def start_watch_thread
317
+ return nil unless watch
318
+
319
+ FileChangeHook.new(self, watch).start
320
+ end
321
+
322
+ def prepare_output_log
323
+ @output_log&.close unless @output_log&.closed?
324
+ @output_log = File.open(output_log_path, "ab")
325
+ @output_log.sync = true
326
+ @output_log_failed = false
327
+ end
328
+
329
+ def install_signal_handlers
330
+ %w[INT TERM HUP QUIT].each do |signal_name|
331
+ Signal.trap(signal_name) { forward_signal(signal_name) }
332
+ end
333
+ Signal.trap("WINCH") { sync_window_size }
334
+ end
335
+
336
+ def forward_signal(signal_name)
337
+ return unless pid
338
+
339
+ Process.kill(signal_name, pid)
340
+ rescue Errno::ESRCH
341
+ nil
342
+ end
343
+
344
+ def record_output(chunk)
345
+ snapshot = @mutex.synchronize do
346
+ append_output_log(chunk)
347
+ @output_buffer << chunk
348
+ overflow = @output_buffer.bytesize - OUTPUT_BUFFER_LIMIT
349
+ @output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
350
+ @output_buffer.dup
351
+ end
352
+ @state_machine.update(snapshot)
353
+ end
354
+
355
+ def append_output_log(chunk)
356
+ return unless @output_log
357
+
358
+ @output_log.write(chunk)
359
+ rescue StandardError => e
360
+ return if defined?(@output_log_failed) && @output_log_failed
361
+
362
+ @output_log_failed = true
363
+ warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
364
+ end
365
+
366
+ def screen_snapshot
367
+ @mutex.synchronize { @output_buffer.dup }
368
+ end
369
+
370
+ def load_existing_registry_metadata
371
+ return {} unless File.exist?(@registry_path)
372
+
373
+ JSON.parse(File.read(@registry_path)).each_with_object({}) do |(key, value), memo|
374
+ memo[key] = value if key.start_with?("tmux_")
375
+ end
376
+ rescue JSON::ParserError
377
+ {}
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,55 @@
1
+ module Harnex
2
+ class SessionState
3
+ STATES = %i[prompt busy blocked unknown].freeze
4
+
5
+ attr_reader :state
6
+
7
+ def initialize(adapter)
8
+ @adapter = adapter
9
+ @state = :unknown
10
+ @mutex = Mutex.new
11
+ @condvar = ConditionVariable.new
12
+ end
13
+
14
+ def update(screen_snapshot)
15
+ input = @adapter.input_state(screen_snapshot)
16
+ new_state =
17
+ case input[:input_ready]
18
+ when true then :prompt
19
+ when false then :blocked
20
+ else :unknown
21
+ end
22
+
23
+ @mutex.synchronize do
24
+ old = @state
25
+ @state = new_state
26
+ @condvar.broadcast if old != new_state
27
+ end
28
+
29
+ new_state
30
+ end
31
+
32
+ def force_busy!
33
+ @mutex.synchronize do
34
+ @state = :busy
35
+ @condvar.broadcast
36
+ end
37
+ end
38
+
39
+ def wait_for_prompt(timeout)
40
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
41
+ @mutex.synchronize do
42
+ loop do
43
+ return true if @state == :prompt
44
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ return false if remaining <= 0
46
+ @condvar.wait(@mutex, remaining)
47
+ end
48
+ end
49
+ end
50
+
51
+ def to_s
52
+ @mutex.synchronize { @state.to_s }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,3 @@
1
+ module Harnex
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,43 @@
1
+ require "fiddle/import"
2
+
3
+ module Harnex
4
+ module Inotify
5
+ extend Fiddle::Importer
6
+
7
+ IN_ATTRIB = 0x00000004
8
+ IN_CLOSE_WRITE = 0x00000008
9
+ IN_CREATE = 0x00000100
10
+ IN_MOVED_TO = 0x00000080
11
+
12
+ @available = false
13
+ begin
14
+ dlload Fiddle.dlopen(nil)
15
+ extern "int inotify_init(void)"
16
+ extern "int inotify_add_watch(int, const char*, unsigned int)"
17
+ @available = true
18
+ rescue Fiddle::DLError
19
+ @available = false
20
+ end
21
+
22
+ class << self
23
+ def available?
24
+ @available
25
+ end
26
+
27
+ def directory_io(path, mask)
28
+ raise "inotify is not available on this system" unless available?
29
+
30
+ fd = inotify_init
31
+ raise "could not initialize file watch" if fd.negative?
32
+
33
+ watch_id = inotify_add_watch(fd, path, mask)
34
+ if watch_id.negative?
35
+ IO.for_fd(fd, autoclose: true)&.close
36
+ raise "could not watch #{path}"
37
+ end
38
+
39
+ IO.for_fd(fd, "rb", autoclose: true)
40
+ end
41
+ end
42
+ end
43
+ end