harnex 0.5.0 → 0.6.2

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.
@@ -0,0 +1,411 @@
1
+ require "json"
2
+ require "open3"
3
+
4
+ module Harnex
5
+ module Adapters
6
+ # Codex `app-server` adapter — JSON-RPC over stdio.
7
+ #
8
+ # Talks to a spawned `codex app-server` subprocess by writing
9
+ # newline-delimited JSON-RPC messages on stdin and reading
10
+ # responses + notifications from stdout. Replaces the pane-scraping
11
+ # heuristics in `Adapters::Codex` (legacy, kept behind --legacy-pty).
12
+ class CodexAppServer < Base
13
+ CLIENT_TITLE = "harnex"
14
+ CLIENT_NAME = "harnex"
15
+
16
+ OPT_OUT_NOTIFICATIONS = %w[
17
+ item/agentMessage/delta
18
+ item/reasoning/summaryTextDelta
19
+ item/reasoning/summaryPartAdded
20
+ item/reasoning/textDelta
21
+ ].freeze
22
+
23
+ REQUEST_METHODS = %w[
24
+ initialize thread/start turn/start turn/interrupt thread/resume
25
+ ].freeze
26
+
27
+ NOTIFICATION_METHODS = %w[
28
+ thread/started turn/started turn/completed
29
+ item/started item/completed
30
+ thread/status/changed thread/tokenUsage/updated
31
+ thread/compacted account/rateLimits/updated
32
+ error
33
+ ].freeze
34
+
35
+ EVENTS = %w[task_complete turn_started item_completed disconnected].freeze
36
+
37
+ attr_reader :thread_id, :current_turn_id, :last_completed_at, :initial_prompt
38
+
39
+ def initialize(extra_args = [])
40
+ super("codex", extra_args)
41
+ @initial_prompt = extra_args.join(" ").strip
42
+ @initial_prompt = nil if @initial_prompt.empty?
43
+ @client = nil
44
+ @thread_id = nil
45
+ @current_turn_id = nil
46
+ @state = :disconnected
47
+ @last_completed_at = nil
48
+ @notification_handler = nil
49
+ @disconnect_handler = nil
50
+ end
51
+
52
+ def transport
53
+ :stdio_jsonrpc
54
+ end
55
+
56
+ def base_command
57
+ ["codex", "app-server"]
58
+ end
59
+
60
+ def build_command
61
+ base_command
62
+ end
63
+
64
+ def describe
65
+ {
66
+ transport: transport,
67
+ request_methods: REQUEST_METHODS,
68
+ notification_methods: NOTIFICATION_METHODS,
69
+ events: EVENTS
70
+ }
71
+ end
72
+
73
+ def state
74
+ @state
75
+ end
76
+
77
+ # Override: state is RPC-driven, screen text is ignored.
78
+ def input_state(_screen_text = nil)
79
+ {
80
+ state: @state.to_s,
81
+ input_ready: @state == :prompt
82
+ }
83
+ end
84
+
85
+ def build_send_payload(text:, submit:, enter_only:, screen_text:, force: false)
86
+ state = input_state(nil)
87
+ if !force && submit && !enter_only && state[:input_ready] != true
88
+ raise ArgumentError, blocked_message(state, enter_only: enter_only)
89
+ end
90
+ raise ArgumentError, "Codex app-server cannot stage input without submitting it" unless submit || enter_only
91
+ raise ArgumentError, "Codex app-server does not support submit-only input" if enter_only
92
+
93
+ {
94
+ dispatch: { prompt: text.to_s },
95
+ input_state: state,
96
+ force: force
97
+ }
98
+ end
99
+
100
+ # No-op: closing the subprocess is handled via #close.
101
+ def inject_exit(_writer, **_kwargs)
102
+ nil
103
+ end
104
+
105
+ def on_notification(&block)
106
+ @notification_handler = block
107
+ end
108
+
109
+ def on_disconnect(&block)
110
+ @disconnect_handler = block
111
+ end
112
+
113
+ # Start the JSON-RPC client. In production, spawns the codex
114
+ # subprocess. In tests, callers may pass pre-built IO objects.
115
+ def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
116
+ if read_io && write_io
117
+ @client = JsonRpcClient.new(read_io: read_io, write_io: write_io, pid: pid)
118
+ else
119
+ spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
120
+ @client = JsonRpcClient.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
121
+ end
122
+
123
+ @client.on_notification { |msg| handle_notification(msg) }
124
+ @client.on_disconnect { |err| handle_disconnect(err) }
125
+ @client.start
126
+ perform_handshake
127
+ @state = :prompt
128
+ self
129
+ end
130
+
131
+ def dispatch(prompt:, model: nil, effort: nil)
132
+ ensure_open!
133
+ ensure_thread!
134
+ params = {
135
+ threadId: @thread_id,
136
+ input: { content: [{ type: "text", text: prompt.to_s }] }
137
+ }
138
+ params[:model] = model if model
139
+ params[:effort] = effort if effort
140
+
141
+ result = @client.request("turn/start", params)
142
+ @current_turn_id = result["turnId"] || result["turn_id"] || result["id"]
143
+ @state = :busy
144
+ @current_turn_id
145
+ end
146
+
147
+ def interrupt(turn_id: nil)
148
+ ensure_open!
149
+ target = turn_id || @current_turn_id
150
+ return nil if target.nil?
151
+
152
+ @client.request("turn/interrupt", { threadId: @thread_id, turnId: target })
153
+ end
154
+
155
+ def resume(thread_id:)
156
+ ensure_open!
157
+ result = @client.request("thread/resume", { threadId: thread_id })
158
+ @thread_id = thread_id
159
+ @state = :prompt
160
+ result
161
+ end
162
+
163
+ def close
164
+ return unless @client
165
+
166
+ @client.close
167
+ @client = nil
168
+ @state = :disconnected
169
+ end
170
+
171
+ def pid
172
+ @client&.pid
173
+ end
174
+
175
+ private
176
+
177
+ def ensure_open!
178
+ raise "codex_appserver: client not started" unless @client
179
+ raise "codex_appserver: disconnected" if @state == :disconnected
180
+ end
181
+
182
+ def ensure_thread!
183
+ return if @thread_id
184
+
185
+ result = @client.request("thread/start", {})
186
+ @thread_id = result["threadId"] || result["thread_id"]
187
+ end
188
+
189
+ def perform_handshake
190
+ @client.request("initialize", {
191
+ clientInfo: {
192
+ title: CLIENT_TITLE,
193
+ name: CLIENT_NAME,
194
+ version: Harnex::VERSION
195
+ },
196
+ capabilities: {
197
+ experimentalApi: false,
198
+ optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
199
+ }
200
+ })
201
+ @client.notify("initialized", {})
202
+ end
203
+
204
+ def handle_notification(message)
205
+ method = message["method"]
206
+ params = message["params"] || {}
207
+
208
+ case method
209
+ when "thread/started"
210
+ @thread_id ||= params["threadId"] || params["thread_id"]
211
+ when "turn/started"
212
+ @current_turn_id = params["turnId"] || params["turn_id"]
213
+ @state = :busy
214
+ when "turn/completed"
215
+ @last_completed_at = Time.now
216
+ @current_turn_id = nil
217
+ @state = :prompt
218
+ when "error"
219
+ @state = :disconnected
220
+ end
221
+
222
+ @notification_handler&.call(message)
223
+ end
224
+
225
+ def handle_disconnect(error)
226
+ @state = :disconnected
227
+ @disconnect_handler&.call(error)
228
+ end
229
+
230
+ def spawn_subprocess(env, cwd)
231
+ spawn_env = env || {}
232
+ opts = {}
233
+ opts[:chdir] = cwd if cwd
234
+ stdin_io, stdout_io, _stderr_io, wait_thr =
235
+ Open3.popen3(spawn_env, *build_command, **opts)
236
+ [wait_thr.pid, stdin_io, stdout_io]
237
+ end
238
+
239
+ def blocked_message(state, enter_only:)
240
+ return super if enter_only
241
+
242
+ "Codex app-server is not at a prompt; wait and retry or use `harnex send --force` (state: #{state[:state]})"
243
+ end
244
+
245
+ # Minimal JSON-RPC 2.0 client. One JSON object per line.
246
+ # Responses keyed by id; everything else is a notification.
247
+ class JsonRpcClient
248
+ attr_reader :pid
249
+
250
+ def initialize(read_io:, write_io:, pid: nil)
251
+ @read_io = read_io
252
+ @write_io = write_io
253
+ @pid = pid
254
+ @next_id = 1
255
+ @pending = {}
256
+ @id_mutex = Mutex.new
257
+ @write_mutex = Mutex.new
258
+ @notification_handler = nil
259
+ @disconnect_handler = nil
260
+ @disconnect_signaled = false
261
+ @closed = false
262
+ @reader_thread = nil
263
+ end
264
+
265
+ def on_notification(&block)
266
+ @notification_handler = block
267
+ end
268
+
269
+ def on_disconnect(&block)
270
+ @disconnect_handler = block
271
+ end
272
+
273
+ def start
274
+ @reader_thread = Thread.new { read_loop }
275
+ end
276
+
277
+ def request(method, params = {})
278
+ raise "codex_appserver client is closed" if @closed
279
+
280
+ queue = Queue.new
281
+ id = @id_mutex.synchronize do
282
+ assigned = @next_id
283
+ @next_id += 1
284
+ @pending[assigned] = queue
285
+ assigned
286
+ end
287
+
288
+ write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
289
+ result = queue.pop
290
+ raise result if result.is_a?(Exception)
291
+
292
+ result
293
+ end
294
+
295
+ def notify(method, params = {})
296
+ return if @closed
297
+
298
+ write_line({ jsonrpc: "2.0", method: method, params: params })
299
+ end
300
+
301
+ def close
302
+ return if @closed
303
+
304
+ @closed = true
305
+
306
+ @id_mutex.synchronize do
307
+ @pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
308
+ @pending.clear
309
+ end
310
+
311
+ begin
312
+ @write_io.close unless @write_io.closed?
313
+ rescue IOError
314
+ nil
315
+ end
316
+
317
+ if @pid && process_alive?(@pid)
318
+ sleep 0.05
319
+ begin
320
+ Process.kill("TERM", @pid)
321
+ rescue Errno::ESRCH
322
+ nil
323
+ end
324
+ end
325
+
326
+ @reader_thread&.join(2)
327
+ end
328
+
329
+ private
330
+
331
+ def write_line(message)
332
+ @write_mutex.synchronize do
333
+ @write_io.write(JSON.generate(message))
334
+ @write_io.write("\n")
335
+ @write_io.flush
336
+ end
337
+ rescue Errno::EPIPE, IOError
338
+ signal_disconnect(nil)
339
+ end
340
+
341
+ def read_loop
342
+ buffer = +""
343
+ loop do
344
+ chunk = @read_io.readpartial(4096)
345
+ buffer << chunk
346
+ while (idx = buffer.index("\n"))
347
+ line = buffer.slice!(0, idx + 1).chomp
348
+ next if line.strip.empty?
349
+
350
+ handle_line(line)
351
+ end
352
+ end
353
+ rescue EOFError, IOError, Errno::EIO
354
+ nil
355
+ ensure
356
+ signal_disconnect(nil)
357
+ end
358
+
359
+ def handle_line(line)
360
+ message = JSON.parse(line)
361
+ rescue JSON::ParserError => e
362
+ signal_disconnect(e)
363
+ return
364
+ else
365
+ dispatch_message(message)
366
+ end
367
+
368
+ def dispatch_message(message)
369
+ if message["id"] && message["method"]
370
+ write_line({
371
+ jsonrpc: "2.0",
372
+ id: message["id"],
373
+ error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
374
+ })
375
+ return
376
+ end
377
+
378
+ if message.key?("id")
379
+ pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
380
+ return unless pending
381
+
382
+ if message["error"]
383
+ err_msg = message.dig("error", "message") || "RPC error"
384
+ pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
385
+ signal_disconnect(message["error"])
386
+ else
387
+ pending.push(message["result"] || {})
388
+ end
389
+ return
390
+ end
391
+
392
+ @notification_handler&.call(message) if message["method"]
393
+ end
394
+
395
+ def signal_disconnect(error)
396
+ return if @disconnect_signaled
397
+
398
+ @disconnect_signaled = true
399
+ @disconnect_handler&.call(error)
400
+ end
401
+
402
+ def process_alive?(pid)
403
+ Process.kill(0, pid)
404
+ true
405
+ rescue Errno::ESRCH, Errno::EPERM
406
+ false
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
@@ -1,6 +1,7 @@
1
1
  require_relative "adapters/base"
2
2
  require_relative "adapters/generic"
3
3
  require_relative "adapters/codex"
4
+ require_relative "adapters/codex_appserver"
4
5
  require_relative "adapters/claude"
5
6
 
6
7
  module Harnex
@@ -15,11 +16,23 @@ module Harnex
15
16
  !key.to_s.strip.empty?
16
17
  end
17
18
 
18
- def build(key, extra_args = [])
19
- adapter_class = registry[key.to_s]
19
+ # Phase 3 flipped the default — `codex` resolves to CodexAppServer.
20
+ # Legacy PTY adapter is reachable via `legacy_pty: true` (driven by
21
+ # `harnex run codex --legacy-pty`). Will be removed in 0.7.0.
22
+ def codex_appserver_enabled?
23
+ true
24
+ end
25
+
26
+ def build(key, extra_args = [], legacy_pty: false)
27
+ key_str = key.to_s
28
+ if key_str == "codex"
29
+ return legacy_pty ? Codex.new(extra_args) : CodexAppServer.new(extra_args)
30
+ end
31
+
32
+ adapter_class = registry[key_str]
20
33
  return adapter_class.new(extra_args) if adapter_class
21
34
 
22
- Generic.new(key.to_s, extra_args)
35
+ Generic.new(key_str, extra_args)
23
36
  end
24
37
 
25
38
  def registry
data/lib/harnex/cli.rb CHANGED
@@ -29,8 +29,10 @@ module Harnex
29
29
  Recipes.new(@argv.drop(1)).run
30
30
  when "guide"
31
31
  Guide.new.run
32
- when "skills"
33
- Skills.new(@argv.drop(1)).run
32
+ when "agents-guide"
33
+ AgentsGuide.new(@argv.drop(1)).run
34
+ when "doctor"
35
+ Doctor.new(@argv.drop(1)).run
34
36
  when "help"
35
37
  puts help(@argv[1])
36
38
  0
@@ -69,8 +71,10 @@ module Harnex
69
71
  Recipes.usage
70
72
  when "guide"
71
73
  Guide.usage
72
- when "skills"
73
- Skills.usage
74
+ when "agents-guide"
75
+ AgentsGuide.usage
76
+ when "doctor"
77
+ Doctor.usage
74
78
  else
75
79
  usage
76
80
  end
@@ -87,6 +91,8 @@ module Harnex
87
91
  harnex logs --id ID [options]
88
92
  harnex events --id ID [options]
89
93
  harnex pane --id ID [options]
94
+ harnex agents-guide [topic]
95
+ harnex doctor
90
96
  harnex help [command]
91
97
 
92
98
  Commands:
@@ -100,10 +106,13 @@ module Harnex
100
106
  pane Capture the current tmux pane for a live session
101
107
  recipes List and read workflow recipes
102
108
  guide Show the getting started guide
103
- skills Install bundled skills into a repo or globally
109
+ agents-guide
110
+ Show agent dispatch, chain, buddy, monitoring, and naming guides
111
+ doctor Run preflight checks for adapter dependencies
104
112
  help Show command help
105
113
 
106
114
  New to harnex? Start with: harnex guide
115
+ Working with agents (dispatching tasks)? Read: harnex agents-guide
107
116
 
108
117
  Notes:
109
118
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
@@ -117,8 +126,9 @@ module Harnex
117
126
  harnex logs --id main --follow
118
127
  harnex events --id main --snapshot
119
128
  harnex pane --id main --lines 40
129
+ harnex agents-guide dispatch
130
+ harnex doctor
120
131
  harnex send --id main --message "Summarize current progress."
121
- harnex skills install
122
132
  TEXT
123
133
  end
124
134
  end
@@ -0,0 +1,109 @@
1
+ module Harnex
2
+ class AgentsGuide
3
+ GUIDES_DIR = File.expand_path("../../../../guides", __FILE__)
4
+
5
+ def self.usage
6
+ <<~TEXT
7
+ Usage: harnex agents-guide [list|show <topic>|<topic>]
8
+
9
+ Subcommands:
10
+ list List available agent guide topics (default)
11
+ show <topic> Print a guide by name or number
12
+
13
+ Examples:
14
+ harnex agents-guide
15
+ harnex agents-guide list
16
+ harnex agents-guide show 01
17
+ harnex agents-guide show dispatch
18
+ harnex agents-guide monitoring
19
+
20
+ Common patterns:
21
+ harnex agents-guide dispatch
22
+ harnex agents-guide chain
23
+ harnex agents-guide naming
24
+
25
+ Gotchas:
26
+ Agent guides replace the old harnex skills install flow.
27
+ They are packaged with the gem and require no external project docs.
28
+ TEXT
29
+ end
30
+
31
+ def initialize(argv)
32
+ @argv = argv.dup
33
+ end
34
+
35
+ def run
36
+ subcommand = @argv.shift
37
+ case subcommand
38
+ when nil, "list"
39
+ list_guides
40
+ when "show"
41
+ show_guide(@argv.first)
42
+ when "-h", "--help"
43
+ puts self.class.usage
44
+ 0
45
+ else
46
+ show_guide(subcommand)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def list_guides
53
+ files = guide_files
54
+ if files.empty?
55
+ puts "No agent guides found."
56
+ return 0
57
+ end
58
+
59
+ puts "Agent guides:\n\n"
60
+ files.each do |file|
61
+ name = File.basename(file, ".md")
62
+ title = extract_title(file)
63
+ puts " #{name} #{title}"
64
+ end
65
+ puts "\nRun `harnex agents-guide show <topic>` to read one."
66
+ 0
67
+ end
68
+
69
+ def show_guide(query)
70
+ unless query
71
+ warn("harnex agents-guide show: topic required")
72
+ return 1
73
+ end
74
+
75
+ file = find_guide(query)
76
+ unless file
77
+ warn("harnex agents-guide: no topic matching #{query.inspect}")
78
+ warn("Run `harnex agents-guide list` to see available topics.")
79
+ return 1
80
+ end
81
+
82
+ puts File.read(file)
83
+ 0
84
+ end
85
+
86
+ def find_guide(query)
87
+ files = guide_files
88
+
89
+ exact = files.find { |file| File.basename(file, ".md") == query }
90
+ return exact if exact
91
+
92
+ prefix = files.find { |file| File.basename(file, ".md").start_with?(query) }
93
+ return prefix if prefix
94
+
95
+ files.find { |file| File.basename(file, ".md").include?(query) }
96
+ end
97
+
98
+ def guide_files
99
+ return [] unless Dir.exist?(GUIDES_DIR)
100
+
101
+ Dir.glob(File.join(GUIDES_DIR, "*.md")).sort
102
+ end
103
+
104
+ def extract_title(file)
105
+ first_line = File.foreach(file).first.to_s.strip
106
+ first_line.start_with?("#") ? first_line.sub(/^#+\s*/, "") : ""
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,83 @@
1
+ require "json"
2
+
3
+ module Harnex
4
+ class Doctor
5
+ MIN_CODEX_VERSION = Gem::Version.new("0.128.0")
6
+
7
+ def self.usage
8
+ <<~TEXT
9
+ Usage: harnex doctor
10
+
11
+ Runs preflight checks for harnex's adapter dependencies.
12
+ Currently verifies that Codex CLI is installed and at version
13
+ >= #{MIN_CODEX_VERSION} (required for the JSON-RPC `app-server`
14
+ adapter).
15
+
16
+ Common patterns:
17
+ harnex doctor
18
+ harnex doctor --help
19
+
20
+ Gotchas:
21
+ doctor validates local adapter prerequisites; it does not start sessions.
22
+ Run it after installing or upgrading Codex CLI.
23
+ TEXT
24
+ end
25
+
26
+ def initialize(argv = [])
27
+ @argv = argv.dup
28
+ end
29
+
30
+ def run
31
+ if @argv.include?("-h") || @argv.include?("--help")
32
+ puts self.class.usage
33
+ return 0
34
+ end
35
+
36
+ checks = [check_codex]
37
+ summary = {
38
+ ok: checks.all? { |c| c[:ok] },
39
+ checks: checks
40
+ }
41
+ puts JSON.generate(summary)
42
+ summary[:ok] ? 0 : 1
43
+ end
44
+
45
+ private
46
+
47
+ def check_codex
48
+ result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
49
+
50
+ version_output, status = capture("codex --version")
51
+ if status.nil?
52
+ return result.merge(ok: false, error: "codex CLI not found on PATH")
53
+ end
54
+ unless status.success?
55
+ return result.merge(ok: false, error: "codex --version failed: #{version_output.strip}")
56
+ end
57
+
58
+ version = parse_version(version_output)
59
+ if version.nil?
60
+ return result.merge(ok: false, found: version_output.strip, error: "could not parse codex version output")
61
+ end
62
+
63
+ if version < MIN_CODEX_VERSION
64
+ return result.merge(ok: false, found: version.to_s,
65
+ error: "codex #{version} < required #{MIN_CODEX_VERSION}; upgrade with `npm i -g @openai/codex` or your platform package manager")
66
+ end
67
+
68
+ result.merge(ok: true, found: version.to_s)
69
+ end
70
+
71
+ def capture(command)
72
+ output = `#{command} 2>&1`
73
+ [output, $?]
74
+ rescue StandardError => e
75
+ [e.message, nil]
76
+ end
77
+
78
+ def parse_version(text)
79
+ match = text.match(/(\d+\.\d+\.\d+)/)
80
+ match ? Gem::Version.new(match[1]) : nil
81
+ end
82
+ end
83
+ end
@@ -18,6 +18,16 @@ module Harnex
18
18
  --snapshot Print current events and exit (alias for --no-follow)
19
19
  --from TS Replay floor (ISO-8601, inclusive)
20
20
  -h, --help Show this help
21
+
22
+ Common patterns:
23
+ #{program_name} --id cx-i-42 --snapshot
24
+ #{program_name} --id cx-i-42
25
+ #{program_name} --id cx-i-42 --from 2026-05-06T10:00:00Z --snapshot
26
+
27
+ Gotchas:
28
+ events is structured JSONL; logs is human transcript text.
29
+ Default mode follows live events. Use --snapshot to print and exit.
30
+ Use wait --until task_complete when you only need a completion fence.
21
31
  TEXT
22
32
  end
23
33