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,134 @@
1
+ module Harnex
2
+ module Adapters
3
+ class Base
4
+ PROMPT_PREFIXES = [">", "\u203A", "\u276F"].freeze
5
+
6
+ # Adapter contract — subclasses MUST implement:
7
+ # base_command -> Array[String] CLI args to spawn
8
+ #
9
+ # Subclasses MAY override:
10
+ # input_state(text) -> Hash Parse screen for state
11
+ # build_send_payload -> Hash Build injection payload
12
+ # inject_exit(writer) -> void Send a stop/exit sequence
13
+ # infer_repo_path(argv) -> String Extract repo path from CLI args
14
+ # wait_for_sendable -> String Wait for a send-ready snapshot
15
+
16
+ attr_reader :key
17
+
18
+ def initialize(key, extra_args = [])
19
+ @key = key
20
+ @extra_args = extra_args.dup
21
+ end
22
+
23
+ def build_command
24
+ base_command + @extra_args
25
+ end
26
+
27
+ def base_command
28
+ raise NotImplementedError, "#{self.class} must define #base_command"
29
+ end
30
+
31
+ def infer_repo_path(_argv)
32
+ Dir.pwd
33
+ end
34
+
35
+ def input_state(screen_text)
36
+ {
37
+ state: "unknown",
38
+ input_ready: nil
39
+ }
40
+ end
41
+
42
+ def send_wait_seconds(submit:, enter_only:)
43
+ 0.0
44
+ end
45
+
46
+ def wait_for_sendable_state?(_state, submit:, enter_only:)
47
+ false
48
+ end
49
+
50
+ def wait_for_sendable(screen_snapshot_fn, submit:, enter_only:, force:)
51
+ snapshot = screen_snapshot_fn.call
52
+ return snapshot if force
53
+
54
+ wait_secs = send_wait_seconds(submit: submit, enter_only: enter_only).to_f
55
+ return snapshot unless wait_secs.positive?
56
+
57
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait_secs
58
+ state = input_state(snapshot)
59
+
60
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline &&
61
+ wait_for_sendable_state?(state, submit: submit, enter_only: enter_only)
62
+ sleep 0.05
63
+ snapshot = screen_snapshot_fn.call
64
+ state = input_state(snapshot)
65
+ end
66
+
67
+ snapshot
68
+ end
69
+
70
+ def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
71
+ state = input_state(screen_text)
72
+ if !force && blocked_state?(state, enter_only: enter_only)
73
+ raise ArgumentError, blocked_message(state, enter_only: enter_only)
74
+ end
75
+
76
+ payload = enter_only ? "" : text.to_s
77
+ payload << submit_bytes if submit || enter_only
78
+
79
+ {
80
+ text: payload,
81
+ newline: false,
82
+ input_state: state,
83
+ force: force
84
+ }
85
+ end
86
+
87
+ def inject_exit(writer, delay_ms: 0)
88
+ writer.write("/exit")
89
+ writer.flush
90
+ sleep(delay_ms / 1000.0) if delay_ms.positive?
91
+ writer.write(submit_bytes)
92
+ writer.flush
93
+ end
94
+
95
+ protected
96
+
97
+ def submit_bytes
98
+ "\r"
99
+ end
100
+
101
+ def blocked_state?(state, enter_only:)
102
+ state[:input_ready] == false && !allow_control_action?(state, enter_only: enter_only)
103
+ end
104
+
105
+ def allow_control_action?(_state, enter_only:)
106
+ enter_only ? false : false
107
+ end
108
+
109
+ def blocked_message(state, enter_only:)
110
+ suffix = enter_only ? " for Enter-only input" : ""
111
+ "session is not ready for #{key} prompt input#{suffix} (state: #{state[:state]})"
112
+ end
113
+
114
+ def prompt_line?(line)
115
+ stripped = line.to_s.strip
116
+ return false if stripped.empty?
117
+ return false if stripped.match?(/\A(?:[>\u203A\u276F]\s*)?\d+\./)
118
+
119
+ PROMPT_PREFIXES.any? { |prefix| stripped.start_with?(prefix) }
120
+ end
121
+
122
+ def recent_lines(screen_text, limit: 40)
123
+ normalized_screen_text(screen_text).lines.last(limit)
124
+ end
125
+
126
+ def normalized_screen_text(screen_text)
127
+ text = screen_text.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "")
128
+ text = text.gsub(/\e\][^\a]*?(?:\a|\e\\)/, "")
129
+ text = text.gsub(/\e(?:[@-Z\\-_]|\[[0-?]*[ -\/]*[@-~])/, "")
130
+ text.gsub(/\r\n?/, "\n")
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,105 @@
1
+ module Harnex
2
+ module Adapters
3
+ class Claude < Base
4
+ SUBMIT_DELAY_MS = 75
5
+ SUBMIT_DELAY_PER_KB_MS = 50
6
+
7
+ def initialize(extra_args = [])
8
+ super("claude", extra_args)
9
+ end
10
+
11
+ def base_command
12
+ [
13
+ "claude",
14
+ "--dangerously-skip-permissions"
15
+ ]
16
+ end
17
+
18
+ def input_state(screen_text)
19
+ lines = recent_lines(screen_text, limit: 20)
20
+ text = lines.join
21
+ compact = text.gsub(/\s+/, "")
22
+
23
+ if compact.include?("Quicksafetycheck:") && compact.include?("Yes,Itrustthisfolder")
24
+ {
25
+ state: "workspace-trust-prompt",
26
+ input_ready: false,
27
+ action: "press-enter-to-confirm"
28
+ }
29
+ elsif compact.include?("Entertoconfirm") && compact.include?("Esctocancel")
30
+ {
31
+ state: "confirmation",
32
+ input_ready: false
33
+ }
34
+ elsif compact.include?("--INSERT--") || compact.include?("bypasspermissionson")
35
+ {
36
+ state: "prompt",
37
+ input_ready: true
38
+ }
39
+ elsif compact.include?("NORMAL") || compact.include?("--NORMAL--")
40
+ {
41
+ state: "vim-normal",
42
+ input_ready: true
43
+ }
44
+ elsif lines.any? { |line| prompt_line?(line) }
45
+ {
46
+ state: "prompt",
47
+ input_ready: true
48
+ }
49
+ else
50
+ super
51
+ end
52
+ end
53
+
54
+ def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
55
+ state = input_state(screen_text)
56
+ if !force && blocked_state?(state, enter_only: enter_only)
57
+ raise ArgumentError, blocked_message(state, enter_only: enter_only)
58
+ end
59
+
60
+ steps = []
61
+ unless enter_only
62
+ body = text.to_s
63
+ steps << { text: body, newline: false } unless body.empty?
64
+ end
65
+
66
+ if submit || enter_only
67
+ step = { text: submit_bytes, newline: false }
68
+ step[:delay_ms] = submit_delay_ms(text) if steps.any?
69
+ steps << step
70
+ end
71
+
72
+ {
73
+ steps: steps,
74
+ input_state: state,
75
+ force: force
76
+ }
77
+ end
78
+
79
+ def inject_exit(writer)
80
+ super(writer, delay_ms: SUBMIT_DELAY_MS)
81
+ end
82
+
83
+ protected
84
+
85
+ def submit_delay_ms(text)
86
+ extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
87
+ SUBMIT_DELAY_MS + extra
88
+ end
89
+
90
+ def allow_control_action?(state, enter_only:)
91
+ enter_only && state[:state] == "workspace-trust-prompt"
92
+ end
93
+
94
+ def blocked_message(state, enter_only:)
95
+ return super unless state[:state] == "workspace-trust-prompt"
96
+
97
+ if enter_only
98
+ "Claude is waiting on the workspace trust prompt"
99
+ else
100
+ "Claude is waiting on the workspace trust prompt; use `harnex send --submit-only` first or `--force` to bypass"
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,112 @@
1
+ module Harnex
2
+ module Adapters
3
+ class Codex < Base
4
+ SUBMIT_DELAY_MS = 75
5
+ SUBMIT_DELAY_PER_KB_MS = 50
6
+ SEND_WAIT_SECONDS = 2.0
7
+
8
+ def initialize(extra_args = [])
9
+ super("codex", extra_args)
10
+ end
11
+
12
+ def base_command
13
+ [
14
+ "codex",
15
+ "--dangerously-bypass-approvals-and-sandbox",
16
+ "--no-alt-screen"
17
+ ]
18
+ end
19
+
20
+ def infer_repo_path(argv)
21
+ index = 0
22
+ while index < argv.length
23
+ arg = argv[index]
24
+ case arg
25
+ when "-C", "--cd"
26
+ next_value = argv[index + 1]
27
+ return next_value if next_value
28
+ break
29
+ when /\A-C(.+)\z/
30
+ return Regexp.last_match(1)
31
+ end
32
+ index += 1
33
+ end
34
+
35
+ Dir.pwd
36
+ end
37
+
38
+ def input_state(screen_text)
39
+ lines = recent_lines(screen_text)
40
+ return super unless lines.any? { |line| line.include?("OpenAI Codex") || line.include?("gpt-") }
41
+
42
+ if lines.any? { |line| prompt_line?(line) }
43
+ {
44
+ state: "prompt",
45
+ input_ready: true
46
+ }
47
+ else
48
+ {
49
+ state: "session",
50
+ input_ready: nil
51
+ }
52
+ end
53
+ end
54
+
55
+ def send_wait_seconds(submit:, enter_only:)
56
+ return 0.0 unless submit
57
+ return 0.0 if enter_only
58
+
59
+ SEND_WAIT_SECONDS
60
+ end
61
+
62
+ def wait_for_sendable_state?(state, submit:, enter_only:)
63
+ return false unless submit
64
+ return false if enter_only
65
+
66
+ state[:input_ready] != true
67
+ end
68
+
69
+ def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
70
+ state = input_state(screen_text)
71
+ if !force && submit && !enter_only && state[:input_ready] != true
72
+ raise ArgumentError, blocked_message(state, enter_only: enter_only)
73
+ end
74
+
75
+ steps = []
76
+ unless enter_only
77
+ body = text.to_s
78
+ steps << { text: body, newline: false } unless body.empty?
79
+ end
80
+
81
+ if submit || enter_only
82
+ step = { text: submit_bytes, newline: false }
83
+ step[:delay_ms] = submit_delay_ms(text) if steps.any?
84
+ steps << step
85
+ end
86
+
87
+ {
88
+ steps: steps,
89
+ input_state: state,
90
+ force: force
91
+ }
92
+ end
93
+
94
+ def inject_exit(writer)
95
+ super(writer, delay_ms: SUBMIT_DELAY_MS)
96
+ end
97
+
98
+ protected
99
+
100
+ def submit_delay_ms(text)
101
+ extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
102
+ SUBMIT_DELAY_MS + extra
103
+ end
104
+
105
+ def blocked_message(state, enter_only:)
106
+ return super if enter_only
107
+
108
+ "Codex is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,14 @@
1
+ module Harnex
2
+ module Adapters
3
+ class Generic < Base
4
+ def initialize(cli_name, extra_args = [])
5
+ @cli_name = cli_name
6
+ super(cli_name, extra_args)
7
+ end
8
+
9
+ def base_command
10
+ [@cli_name]
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,32 @@
1
+ require_relative "adapters/base"
2
+ require_relative "adapters/generic"
3
+ require_relative "adapters/codex"
4
+ require_relative "adapters/claude"
5
+
6
+ module Harnex
7
+ module Adapters
8
+ module_function
9
+
10
+ def known
11
+ registry.keys.sort
12
+ end
13
+
14
+ def supported?(key)
15
+ !key.to_s.strip.empty?
16
+ end
17
+
18
+ def build(key, extra_args = [])
19
+ adapter_class = registry[key.to_s]
20
+ return adapter_class.new(extra_args) if adapter_class
21
+
22
+ Generic.new(key.to_s, extra_args)
23
+ end
24
+
25
+ def registry
26
+ @registry ||= {
27
+ "claude" => Claude,
28
+ "codex" => Codex
29
+ }
30
+ end
31
+ end
32
+ end
data/lib/harnex/cli.rb ADDED
@@ -0,0 +1,115 @@
1
+ module Harnex
2
+ class CLI
3
+ def initialize(argv)
4
+ @argv = argv.dup
5
+ end
6
+
7
+ def run
8
+ case @argv.first
9
+ when nil
10
+ puts usage
11
+ 0
12
+ when "run"
13
+ Runner.new(@argv.drop(1)).run
14
+ when "send"
15
+ Sender.new(@argv.drop(1)).run
16
+ when "wait"
17
+ Waiter.new(@argv.drop(1)).run
18
+ when "stop"
19
+ Stopper.new(@argv.drop(1)).run
20
+ when "status"
21
+ Status.new(@argv.drop(1)).run
22
+ when "logs"
23
+ Logs.new(@argv.drop(1)).run
24
+ when "pane"
25
+ Pane.new(@argv.drop(1)).run
26
+ when "recipes"
27
+ Recipes.new(@argv.drop(1)).run
28
+ when "guide"
29
+ Guide.new.run
30
+ when "skills"
31
+ Skills.new(@argv.drop(1)).run
32
+ when "help"
33
+ puts help(@argv[1])
34
+ 0
35
+ when "-h", "--help"
36
+ puts usage
37
+ 0
38
+ else
39
+ raise OptionParser::ParseError, "unknown command #{@argv.first.inspect}"
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def help(topic)
46
+ case topic
47
+ when "run"
48
+ Runner.usage
49
+ when "send"
50
+ Sender.usage
51
+ when "wait"
52
+ Waiter.usage
53
+ when "stop"
54
+ Stopper.usage
55
+ when "status"
56
+ Status.usage
57
+ when "logs"
58
+ Logs.usage
59
+ when "pane"
60
+ Pane.usage
61
+ when "recipes"
62
+ Recipes.usage
63
+ when "guide"
64
+ Guide.usage
65
+ when "skills"
66
+ Skills.usage
67
+ else
68
+ usage
69
+ end
70
+ end
71
+
72
+ def usage
73
+ <<~TEXT
74
+ Usage:
75
+ harnex run <cli> [options] [--] [cli-args...]
76
+ harnex send --id ID [options] [text...]
77
+ harnex wait --id ID [options]
78
+ harnex stop --id ID [options]
79
+ harnex status [options]
80
+ harnex logs --id ID [options]
81
+ harnex pane --id ID [options]
82
+ harnex help [command]
83
+
84
+ Commands:
85
+ run Start a wrapped interactive session and local API
86
+ send Send text to an active session
87
+ wait Block until a session exits or reaches a state
88
+ stop Send the adapter stop sequence to a session
89
+ status List live sessions
90
+ logs Read session output transcripts
91
+ pane Capture the current tmux pane for a live session
92
+ recipes List and read workflow recipes
93
+ guide Show the getting started guide
94
+ skills Install bundled skills into a repo or globally
95
+ help Show command help
96
+
97
+ New to harnex? Start with: harnex guide
98
+
99
+ Notes:
100
+ CLIs with smart prompt detection: #{Adapters.known.join(', ')}
101
+ Any other CLI name is launched with generic wrapping.
102
+
103
+ Examples:
104
+ harnex run codex
105
+ harnex run aider --id blue-cat
106
+ harnex run codex -- --cd /path/to/repo
107
+ harnex status
108
+ harnex logs --id main --follow
109
+ harnex pane --id main --lines 40
110
+ harnex send --id main --message "Summarize current progress."
111
+ harnex skills install close
112
+ TEXT
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,23 @@
1
+ module Harnex
2
+ class Guide
3
+ GUIDE_PATH = File.expand_path("../../../../GUIDE.md", __FILE__)
4
+
5
+ def self.usage
6
+ <<~TEXT
7
+ Usage: harnex guide
8
+
9
+ Print the getting started guide.
10
+ TEXT
11
+ end
12
+
13
+ def run
14
+ unless File.exist?(GUIDE_PATH)
15
+ warn("harnex guide: GUIDE.md not found at #{GUIDE_PATH}")
16
+ return 1
17
+ end
18
+
19
+ puts File.read(GUIDE_PATH)
20
+ 0
21
+ end
22
+ end
23
+ end