harnex 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2b38fbca70a1ec608f414f362bb16bff85bdb5c279a5c562c4dc7048fa6acb41
4
- data.tar.gz: 7a9048de3efb9461489ab0678683e0d245e60e2ac3a1dd7e617d868fce927a51
3
+ metadata.gz: 0003e780e17784a566b09e37d39ca760531d78ef8b912093944bb7f1eb65c134
4
+ data.tar.gz: 58f1ec4d5919c19a7b445900aff28143636a530470e7d04bf1962532661d308e
5
5
  SHA512:
6
- metadata.gz: a222bd5df7a1e02e7b6cebb2e0a63649b4433baef17be35a3dd03b4128e9b406af52ae71e9c063a6912dbb9af04b187072a693271fb72f24e4d61de462a7bf1c
7
- data.tar.gz: eb2ca6564d079b0e162e72e9d07d01239ba2ce0c5b118fbd4360671d555c452571b2c64cb0f5a4be3c0b23c13e57b606d7ca98632d345f7570d5c69caacc9ad8
6
+ metadata.gz: f979f0c86edf2ac6a694b20e75392ed23059a43a38ea4de7a3620c3270589d08bacd880e5c4ec660859b4430528bb8ae56b90399397010fdb87e06c9ac926847
7
+ data.tar.gz: f109e68a8165cef5627ffa5a2a0ca1afb8e8ace1809591bdefdce6a7e7e8259c2db5e8764bbf06ec191ef7197beef45653377f7e5f0742535ffe5402f28ce12d
data/CHANGELOG.md ADDED
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0 — 2026-05-06
4
+
5
+ ### Architectural pivot: Codex on JSON-RPC
6
+
7
+ harnex now speaks `codex app-server` JSON-RPC over stdio for the
8
+ Codex adapter. Pane-scraping is retired for Codex. Closed by
9
+ construction:
10
+
11
+ - #22 (Codex side; `--watch --stall-after` still applies to
12
+ claude/generic)
13
+ - #24 (disconnect detection — `error` notifications and JSON-RPC
14
+ error responses replace screen-text regex)
15
+ - #25 (first-class completion signal — `turn/completed` is it)
16
+
17
+ ### New
18
+
19
+ - `harnex wait --until task_complete` — block until a turn completes.
20
+ Example: `harnex wait --id cx-i-242 --until task_complete`.
21
+ Adapter-agnostic; tails the events JSONL.
22
+ - `harnex status --json` includes `last_completed_at`, `model`,
23
+ `effort`, `auto_disconnects`.
24
+ - `harnex doctor` preflight checks Codex CLI ≥ 0.128.0.
25
+ - `Adapter#transport` and `Adapter#describe` extension points so
26
+ callers can introspect adapter contracts. Default is
27
+ `:pty` for backward compatibility.
28
+
29
+ ### Migration
30
+
31
+ - Codex CLI ≥ 0.128.0 required.
32
+ - Existing `harnex run codex ...` invocations work unchanged.
33
+ - Emergency fallback: `harnex run codex --legacy-pty ...` (the
34
+ pre-0.6.0 PTY adapter). Deprecated; will be removed in 0.7.0.
35
+
36
+ ### Cross-repo
37
+
38
+ - Resolves holm #201 from the harnex side. holm #271 (substrate v2
39
+ meta) tracks the broader pivot.
data/TECHNICAL.md CHANGED
@@ -321,7 +321,25 @@ The adapter reads the screen and returns a state hash:
321
321
  | `confirmation` | `false` | Modal confirmation |
322
322
  | `unknown` | `nil` | Can't determine |
323
323
 
324
- ### Codex Adapter
324
+ ### Codex Adapter (default — JSON-RPC `app-server`)
325
+
326
+ - `transport :stdio_jsonrpc` — speaks JSON-RPC 2.0 over the
327
+ subprocess's stdin/stdout, one JSON object per line.
328
+ - Launches `codex app-server` (Codex CLI ≥ 0.128.0; verify with
329
+ `harnex doctor`).
330
+ - Notifications (`turn/started`, `turn/completed`, `item/completed`,
331
+ `error`, `thread/compacted`, …) fan into the events log.
332
+ `task_complete` is the harnex-side event for `turn/completed`.
333
+ - Disconnect is detected from JSON-RPC error responses, subprocess
334
+ EOF, parse errors, or a server `error` notification — no screen
335
+ regex required.
336
+ - Synthesized transcript: `item/completed` text payloads stream to
337
+ both the output log and STDOUT so tmux/pane workflows continue to
338
+ work without a real PTY.
339
+ - See `docs/codex-appserver.md` for the full mapping table and
340
+ troubleshooting.
341
+
342
+ #### Codex Adapter (legacy PTY — `--legacy-pty`, removal in 0.7.0)
325
343
 
326
344
  - Launches with `--no-alt-screen` for inline screen output
327
345
  - Detects prompt by looking for `›` prefix in recent lines
@@ -20,6 +20,16 @@ module Harnex
20
20
  @extra_args = extra_args.dup
21
21
  end
22
22
 
23
+ # Default transport. Adapters speaking JSON-RPC override to
24
+ # :stdio_jsonrpc; Session#run uses this to pick the I/O path.
25
+ def transport
26
+ :pty
27
+ end
28
+
29
+ def describe
30
+ { transport: transport }
31
+ end
32
+
23
33
  def build_command
24
34
  base_command + @extra_args
25
35
  end
@@ -0,0 +1,390 @@
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
38
+
39
+ def initialize(extra_args = [])
40
+ super("codex", extra_args)
41
+ @client = nil
42
+ @thread_id = nil
43
+ @current_turn_id = nil
44
+ @state = :disconnected
45
+ @last_completed_at = nil
46
+ @notification_handler = nil
47
+ @disconnect_handler = nil
48
+ end
49
+
50
+ def transport
51
+ :stdio_jsonrpc
52
+ end
53
+
54
+ def base_command
55
+ ["codex", "app-server"]
56
+ end
57
+
58
+ def describe
59
+ {
60
+ transport: transport,
61
+ request_methods: REQUEST_METHODS,
62
+ notification_methods: NOTIFICATION_METHODS,
63
+ events: EVENTS
64
+ }
65
+ end
66
+
67
+ def state
68
+ @state
69
+ end
70
+
71
+ # Override: state is RPC-driven, screen text is ignored.
72
+ def input_state(_screen_text = nil)
73
+ {
74
+ state: @state.to_s,
75
+ input_ready: @state == :prompt
76
+ }
77
+ end
78
+
79
+ # The screen-based send path is not used for stdio_jsonrpc.
80
+ # Session#run_jsonrpc routes through #dispatch instead.
81
+ def build_send_payload(*)
82
+ raise NotImplementedError, "codex_appserver uses #dispatch, not screen-based send"
83
+ end
84
+
85
+ # No-op: closing the subprocess is handled via #close.
86
+ def inject_exit(_writer, **_kwargs)
87
+ nil
88
+ end
89
+
90
+ def on_notification(&block)
91
+ @notification_handler = block
92
+ end
93
+
94
+ def on_disconnect(&block)
95
+ @disconnect_handler = block
96
+ end
97
+
98
+ # Start the JSON-RPC client. In production, spawns the codex
99
+ # subprocess. In tests, callers may pass pre-built IO objects.
100
+ def start_rpc(env: nil, cwd: nil, read_io: nil, write_io: nil, pid: nil)
101
+ if read_io && write_io
102
+ @client = JsonRpcClient.new(read_io: read_io, write_io: write_io, pid: pid)
103
+ else
104
+ spawn_pid, child_stdin, child_stdout = spawn_subprocess(env, cwd)
105
+ @client = JsonRpcClient.new(read_io: child_stdout, write_io: child_stdin, pid: spawn_pid)
106
+ end
107
+
108
+ @client.on_notification { |msg| handle_notification(msg) }
109
+ @client.on_disconnect { |err| handle_disconnect(err) }
110
+ @client.start
111
+ perform_handshake
112
+ @state = :prompt
113
+ self
114
+ end
115
+
116
+ def dispatch(prompt:, model: nil, effort: nil)
117
+ ensure_open!
118
+ ensure_thread!
119
+ params = {
120
+ threadId: @thread_id,
121
+ input: { content: [{ type: "text", text: prompt.to_s }] }
122
+ }
123
+ params[:model] = model if model
124
+ params[:effort] = effort if effort
125
+
126
+ result = @client.request("turn/start", params)
127
+ @current_turn_id = result["turnId"] || result["turn_id"] || result["id"]
128
+ @state = :busy
129
+ @current_turn_id
130
+ end
131
+
132
+ def interrupt(turn_id: nil)
133
+ ensure_open!
134
+ target = turn_id || @current_turn_id
135
+ return nil if target.nil?
136
+
137
+ @client.request("turn/interrupt", { threadId: @thread_id, turnId: target })
138
+ end
139
+
140
+ def resume(thread_id:)
141
+ ensure_open!
142
+ result = @client.request("thread/resume", { threadId: thread_id })
143
+ @thread_id = thread_id
144
+ @state = :prompt
145
+ result
146
+ end
147
+
148
+ def close
149
+ return unless @client
150
+
151
+ @client.close
152
+ @client = nil
153
+ @state = :disconnected
154
+ end
155
+
156
+ def pid
157
+ @client&.pid
158
+ end
159
+
160
+ private
161
+
162
+ def ensure_open!
163
+ raise "codex_appserver: client not started" unless @client
164
+ raise "codex_appserver: disconnected" if @state == :disconnected
165
+ end
166
+
167
+ def ensure_thread!
168
+ return if @thread_id
169
+
170
+ result = @client.request("thread/start", {})
171
+ @thread_id = result["threadId"] || result["thread_id"]
172
+ end
173
+
174
+ def perform_handshake
175
+ @client.request("initialize", {
176
+ clientInfo: {
177
+ title: CLIENT_TITLE,
178
+ name: CLIENT_NAME,
179
+ version: Harnex::VERSION
180
+ },
181
+ capabilities: {
182
+ experimentalApi: false,
183
+ optOutNotificationMethods: OPT_OUT_NOTIFICATIONS
184
+ }
185
+ })
186
+ @client.notify("initialized", {})
187
+ end
188
+
189
+ def handle_notification(message)
190
+ method = message["method"]
191
+ params = message["params"] || {}
192
+
193
+ case method
194
+ when "thread/started"
195
+ @thread_id ||= params["threadId"] || params["thread_id"]
196
+ when "turn/started"
197
+ @current_turn_id = params["turnId"] || params["turn_id"]
198
+ @state = :busy
199
+ when "turn/completed"
200
+ @last_completed_at = Time.now
201
+ @current_turn_id = nil
202
+ @state = :prompt
203
+ when "error"
204
+ @state = :disconnected
205
+ end
206
+
207
+ @notification_handler&.call(message)
208
+ end
209
+
210
+ def handle_disconnect(error)
211
+ @state = :disconnected
212
+ @disconnect_handler&.call(error)
213
+ end
214
+
215
+ def spawn_subprocess(env, cwd)
216
+ spawn_env = env || {}
217
+ opts = {}
218
+ opts[:chdir] = cwd if cwd
219
+ stdin_io, stdout_io, _stderr_io, wait_thr =
220
+ Open3.popen3(spawn_env, *build_command, **opts)
221
+ [wait_thr.pid, stdin_io, stdout_io]
222
+ end
223
+
224
+ # Minimal JSON-RPC 2.0 client. One JSON object per line.
225
+ # Responses keyed by id; everything else is a notification.
226
+ class JsonRpcClient
227
+ attr_reader :pid
228
+
229
+ def initialize(read_io:, write_io:, pid: nil)
230
+ @read_io = read_io
231
+ @write_io = write_io
232
+ @pid = pid
233
+ @next_id = 1
234
+ @pending = {}
235
+ @id_mutex = Mutex.new
236
+ @write_mutex = Mutex.new
237
+ @notification_handler = nil
238
+ @disconnect_handler = nil
239
+ @disconnect_signaled = false
240
+ @closed = false
241
+ @reader_thread = nil
242
+ end
243
+
244
+ def on_notification(&block)
245
+ @notification_handler = block
246
+ end
247
+
248
+ def on_disconnect(&block)
249
+ @disconnect_handler = block
250
+ end
251
+
252
+ def start
253
+ @reader_thread = Thread.new { read_loop }
254
+ end
255
+
256
+ def request(method, params = {})
257
+ raise "codex_appserver client is closed" if @closed
258
+
259
+ queue = Queue.new
260
+ id = @id_mutex.synchronize do
261
+ assigned = @next_id
262
+ @next_id += 1
263
+ @pending[assigned] = queue
264
+ assigned
265
+ end
266
+
267
+ write_line({ jsonrpc: "2.0", id: id, method: method, params: params })
268
+ result = queue.pop
269
+ raise result if result.is_a?(Exception)
270
+
271
+ result
272
+ end
273
+
274
+ def notify(method, params = {})
275
+ return if @closed
276
+
277
+ write_line({ jsonrpc: "2.0", method: method, params: params })
278
+ end
279
+
280
+ def close
281
+ return if @closed
282
+
283
+ @closed = true
284
+
285
+ @id_mutex.synchronize do
286
+ @pending.each_value { |q| q.push(StandardError.new("codex_appserver client closed")) }
287
+ @pending.clear
288
+ end
289
+
290
+ begin
291
+ @write_io.close unless @write_io.closed?
292
+ rescue IOError
293
+ nil
294
+ end
295
+
296
+ if @pid && process_alive?(@pid)
297
+ sleep 0.05
298
+ begin
299
+ Process.kill("TERM", @pid)
300
+ rescue Errno::ESRCH
301
+ nil
302
+ end
303
+ end
304
+
305
+ @reader_thread&.join(2)
306
+ end
307
+
308
+ private
309
+
310
+ def write_line(message)
311
+ @write_mutex.synchronize do
312
+ @write_io.write(JSON.generate(message))
313
+ @write_io.write("\n")
314
+ @write_io.flush
315
+ end
316
+ rescue Errno::EPIPE, IOError
317
+ signal_disconnect(nil)
318
+ end
319
+
320
+ def read_loop
321
+ buffer = +""
322
+ loop do
323
+ chunk = @read_io.readpartial(4096)
324
+ buffer << chunk
325
+ while (idx = buffer.index("\n"))
326
+ line = buffer.slice!(0, idx + 1).chomp
327
+ next if line.strip.empty?
328
+
329
+ handle_line(line)
330
+ end
331
+ end
332
+ rescue EOFError, IOError, Errno::EIO
333
+ nil
334
+ ensure
335
+ signal_disconnect(nil)
336
+ end
337
+
338
+ def handle_line(line)
339
+ message = JSON.parse(line)
340
+ rescue JSON::ParserError => e
341
+ signal_disconnect(e)
342
+ return
343
+ else
344
+ dispatch_message(message)
345
+ end
346
+
347
+ def dispatch_message(message)
348
+ if message["id"] && message["method"]
349
+ write_line({
350
+ jsonrpc: "2.0",
351
+ id: message["id"],
352
+ error: { code: -32601, message: "Unsupported server request: #{message['method']}" }
353
+ })
354
+ return
355
+ end
356
+
357
+ if message.key?("id")
358
+ pending = @id_mutex.synchronize { @pending.delete(message["id"]) }
359
+ return unless pending
360
+
361
+ if message["error"]
362
+ err_msg = message.dig("error", "message") || "RPC error"
363
+ pending.push(StandardError.new("codex_appserver RPC error: #{err_msg}"))
364
+ signal_disconnect(message["error"])
365
+ else
366
+ pending.push(message["result"] || {})
367
+ end
368
+ return
369
+ end
370
+
371
+ @notification_handler&.call(message) if message["method"]
372
+ end
373
+
374
+ def signal_disconnect(error)
375
+ return if @disconnect_signaled
376
+
377
+ @disconnect_signaled = true
378
+ @disconnect_handler&.call(error)
379
+ end
380
+
381
+ def process_alive?(pid)
382
+ Process.kill(0, pid)
383
+ true
384
+ rescue Errno::ESRCH, Errno::EPERM
385
+ false
386
+ end
387
+ end
388
+ end
389
+ end
390
+ 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
@@ -31,6 +31,8 @@ module Harnex
31
31
  Guide.new.run
32
32
  when "skills"
33
33
  Skills.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
@@ -101,6 +103,7 @@ module Harnex
101
103
  recipes List and read workflow recipes
102
104
  guide Show the getting started guide
103
105
  skills Install bundled skills into a repo or globally
106
+ doctor Run preflight checks for adapter dependencies
104
107
  help Show command help
105
108
 
106
109
  New to harnex? Start with: harnex guide
@@ -0,0 +1,75 @@
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
+ TEXT
16
+ end
17
+
18
+ def initialize(argv = [])
19
+ @argv = argv.dup
20
+ end
21
+
22
+ def run
23
+ if @argv.include?("-h") || @argv.include?("--help")
24
+ puts self.class.usage
25
+ return 0
26
+ end
27
+
28
+ checks = [check_codex]
29
+ summary = {
30
+ ok: checks.all? { |c| c[:ok] },
31
+ checks: checks
32
+ }
33
+ puts JSON.generate(summary)
34
+ summary[:ok] ? 0 : 1
35
+ end
36
+
37
+ private
38
+
39
+ def check_codex
40
+ result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
41
+
42
+ version_output, status = capture("codex --version")
43
+ if status.nil?
44
+ return result.merge(ok: false, error: "codex CLI not found on PATH")
45
+ end
46
+ unless status.success?
47
+ return result.merge(ok: false, error: "codex --version failed: #{version_output.strip}")
48
+ end
49
+
50
+ version = parse_version(version_output)
51
+ if version.nil?
52
+ return result.merge(ok: false, found: version_output.strip, error: "could not parse codex version output")
53
+ end
54
+
55
+ if version < MIN_CODEX_VERSION
56
+ return result.merge(ok: false, found: version.to_s,
57
+ error: "codex #{version} < required #{MIN_CODEX_VERSION}; upgrade with `npm i -g @openai/codex` or your platform package manager")
58
+ end
59
+
60
+ result.merge(ok: true, found: version.to_s)
61
+ end
62
+
63
+ def capture(command)
64
+ output = `#{command} 2>&1`
65
+ [output, $?]
66
+ rescue StandardError => e
67
+ [e.message, nil]
68
+ end
69
+
70
+ def parse_version(text)
71
+ match = text.match(/(\d+\.\d+\.\d+)/)
72
+ match ? Gem::Version.new(match[1]) : nil
73
+ end
74
+ end
75
+ end
@@ -8,7 +8,7 @@ module Harnex
8
8
  KNOWN_FLAGS = %w[
9
9
  --id --description --detach --tmux --host --port --watch --watch-file
10
10
  --stall-after --max-resumes --preset --context --meta --summary-out
11
- --timeout --inbox-ttl --help
11
+ --timeout --inbox-ttl --legacy-pty --help
12
12
  ].freeze
13
13
  VALUE_FLAGS = %w[
14
14
  --id --description --host --port --watch --watch-file --stall-after
@@ -36,6 +36,9 @@ module Harnex
36
36
  --summary-out PATH Append dispatch telemetry summary JSONL to PATH
37
37
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
38
38
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
39
+ --legacy-pty (codex only) Use the legacy PTY adapter instead of
40
+ the JSON-RPC `app-server` adapter. Deprecated; will
41
+ be removed in 0.7.0.
39
42
  -h, --help Show this help
40
43
 
41
44
  Notes:
@@ -70,6 +73,7 @@ module Harnex
70
73
  tmux_name: nil,
71
74
  timeout: DEFAULT_TIMEOUT,
72
75
  inbox_ttl: default_inbox_ttl,
76
+ legacy_pty: false,
73
77
  help: false
74
78
  }
75
79
  end
@@ -88,7 +92,7 @@ module Harnex
88
92
  @options[:id] ||= Harnex.generate_id(repo_root)
89
93
  validate_unique_id!(repo_root)
90
94
  effective_child_args = apply_context(child_args)
91
- adapter = Harnex.build_adapter(cli_name, effective_child_args)
95
+ adapter = Harnex.build_adapter(cli_name, effective_child_args, legacy_pty: @options[:legacy_pty])
92
96
  @options[:detach] = true if @options[:tmux]
93
97
  validate_watch_mode!
94
98
  resolve_watch_preset!
@@ -146,6 +150,7 @@ module Harnex
146
150
  tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
147
151
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
148
152
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
153
+ tmux_cmd += ["--legacy-pty"] if @options[:legacy_pty]
149
154
  tmux_cmd += ["--"] + child_args unless child_args.empty?
150
155
 
151
156
  window_name = @options[:tmux_name] || @options[:id]
@@ -254,7 +259,7 @@ module Harnex
254
259
  end
255
260
 
256
261
  def adapter_repo_path(cli_name, child_args)
257
- Harnex.build_adapter(cli_name, child_args).infer_repo_path(child_args)
262
+ Harnex.build_adapter(cli_name, child_args, legacy_pty: @options[:legacy_pty]).infer_repo_path(child_args)
258
263
  end
259
264
 
260
265
  def apply_context(child_args)
@@ -415,6 +420,8 @@ module Harnex
415
420
  @options[:inbox_ttl] = Float(required_option_value(arg, argv[index]))
416
421
  when /\A--inbox-ttl=(.+)\z/
417
422
  @options[:inbox_ttl] = Float(required_option_value("--inbox-ttl", Regexp.last_match(1)))
423
+ when "--legacy-pty"
424
+ @options[:legacy_pty] = true
418
425
  else
419
426
  if cli_name.nil?
420
427
  cli_name = arg
@@ -454,7 +461,7 @@ module Harnex
454
461
  case arg
455
462
  when "--"
456
463
  return false
457
- when "-h", "--help", "--detach", "--tmux"
464
+ when "-h", "--help", "--detach", "--tmux", "--legacy-pty"
458
465
  nil
459
466
  when /\A--tmux=/
460
467
  nil
@@ -7,14 +7,20 @@ module Harnex
7
7
  class Waiter
8
8
  POLL_INTERVAL = 0.5
9
9
 
10
+ EVENT_PREDICATES = %w[task_complete].freeze
11
+
10
12
  def self.usage(program_name = "harnex wait")
11
13
  <<~TEXT
12
14
  Usage: #{program_name} [options]
13
15
 
14
16
  Options:
15
17
  --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
+ --until STATE Wait until session reaches STATE. Supported:
19
+ task_complete (events JSONL fires on
20
+ turn/completed; adapter-agnostic)
21
+ <other> (agent_state HTTP poll, e.g.
22
+ "prompt", "busy")
23
+ Without --until, waits for session exit (default).
18
24
  --repo PATH Resolve session using PATH's repo root (default: current repo)
19
25
  --timeout SECS Maximum time to wait in seconds (default: unlimited)
20
26
  -h, --help Show this help
@@ -42,7 +48,11 @@ module Harnex
42
48
  raise "--id is required for harnex wait" unless @options[:id]
43
49
 
44
50
  if @options[:until_state]
45
- wait_until_state
51
+ if EVENT_PREDICATES.include?(@options[:until_state])
52
+ wait_until_event(@options[:until_state])
53
+ else
54
+ wait_until_state
55
+ end
46
56
  else
47
57
  wait_until_exit
48
58
  end
@@ -50,6 +60,102 @@ module Harnex
50
60
 
51
61
  private
52
62
 
63
+ def wait_until_event(predicate)
64
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
65
+ events_path = Harnex.events_log_path(repo_root, @options[:id])
66
+ registry = Harnex.read_registry(repo_root, @options[:id])
67
+ start_time = Time.now
68
+ deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
69
+
70
+ unless registry || File.exist?(events_path)
71
+ warn("harnex wait: no session found with id #{@options[:id].inspect}")
72
+ return 1
73
+ end
74
+
75
+ offset = 0
76
+ task_complete_seen = false
77
+
78
+ # Replay existing events first — we may already be past the predicate.
79
+ if File.exist?(events_path)
80
+ File.open(events_path, "r") do |f|
81
+ f.each_line do |line|
82
+ offset = f.pos
83
+ event = parse_event(line)
84
+ next unless event
85
+ task_complete_seen = true if event["type"] == "task_complete"
86
+ if matches?(event, predicate, task_complete_seen)
87
+ return emit_event_match(event, start_time)
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ target_pid = registry && registry["pid"]
94
+
95
+ loop do
96
+ if target_pid && !Harnex.alive_pid?(target_pid)
97
+ waited = (Time.now - start_time).round(1)
98
+ puts JSON.generate(ok: false, id: @options[:id], state: "exited", waited_seconds: waited)
99
+ return 1
100
+ end
101
+
102
+ if deadline && Time.now >= deadline
103
+ waited = (Time.now - start_time).round(1)
104
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited)
105
+ return 124
106
+ end
107
+
108
+ if File.exist?(events_path) && File.size(events_path) > offset
109
+ File.open(events_path, "r") do |f|
110
+ f.seek(offset)
111
+ f.each_line do |line|
112
+ event = parse_event(line)
113
+ next unless event
114
+ task_complete_seen = true if event["type"] == "task_complete"
115
+ if matches?(event, predicate, task_complete_seen)
116
+ offset = f.pos
117
+ return emit_event_match(event, start_time)
118
+ end
119
+ end
120
+ offset = f.pos
121
+ end
122
+ end
123
+
124
+ sleep 0.1
125
+ end
126
+ end
127
+
128
+ def parse_event(line)
129
+ JSON.parse(line)
130
+ rescue JSON::ParserError
131
+ nil
132
+ end
133
+
134
+ def matches?(event, predicate, task_complete_seen)
135
+ type = event["type"]
136
+ case predicate
137
+ when "task_complete"
138
+ type == "task_complete"
139
+ when "prompt"
140
+ type == "task_complete" ||
141
+ (task_complete_seen && type == "agent_state" && event["state"] == "prompt")
142
+ else
143
+ false
144
+ end
145
+ end
146
+
147
+ def emit_event_match(event, start_time)
148
+ waited = (Time.now - start_time).round(1)
149
+ puts JSON.generate(
150
+ ok: true,
151
+ id: @options[:id],
152
+ event: event["type"],
153
+ seq: event["seq"],
154
+ waited_seconds: waited
155
+ )
156
+ 0
157
+ end
158
+
53
159
  def wait_until_state
54
160
  repo_root = Harnex.resolve_repo_root(@options[:repo_path])
55
161
  target_state = @options[:until_state]
data/lib/harnex/core.rb CHANGED
@@ -349,10 +349,10 @@ module Harnex
349
349
  false
350
350
  end
351
351
 
352
- def build_adapter(cli, argv)
352
+ def build_adapter(cli, argv, legacy_pty: false)
353
353
  raise ArgumentError, "cli is required" if cli.to_s.strip.empty?
354
354
 
355
- Adapters.build(cli, argv)
355
+ Adapters.build(cli, argv, legacy_pty: legacy_pty)
356
356
  end
357
357
 
358
358
  def session_cli(session)
@@ -25,7 +25,7 @@ module Harnex
25
25
  @counts[:stalls] += 1
26
26
  when "resume"
27
27
  @counts[:force_resumes] += 1
28
- when "disconnect", "disconnection"
28
+ when "disconnect", "disconnection", "disconnected"
29
29
  @counts[:disconnections] += 1
30
30
  when "compaction"
31
31
  @counts[:compactions] += 1
@@ -74,6 +74,7 @@ module Harnex
74
74
  @usage_summary = {}
75
75
  @ended_at = nil
76
76
  @exit_reason = nil
77
+ @last_completed_at = nil
77
78
  @writer = nil
78
79
  @pid = nil
79
80
  @term_signal = nil
@@ -105,6 +106,13 @@ module Harnex
105
106
  validate_binary! if validate_binary
106
107
  prepare_output_log
107
108
  prepare_events_log
109
+
110
+ return run_jsonrpc if adapter.transport == :stdio_jsonrpc
111
+
112
+ run_pty
113
+ end
114
+
115
+ def run_pty
108
116
  @reader, @writer, @pid = PTY.spawn(child_env, *command)
109
117
  @writer.sync = true
110
118
  emit_started_event
@@ -179,6 +187,10 @@ module Harnex
179
187
  payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
180
188
  payload[:agent_state] = @state_machine.to_s
181
189
  payload[:inbox] = @inbox.stats
190
+ payload[:last_completed_at] = @last_completed_at&.iso8601
191
+ payload[:model] = meta_hash["model"]
192
+ payload[:effort] = meta_hash["effort"]
193
+ payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
182
194
  payload
183
195
  end
184
196
 
@@ -193,6 +205,18 @@ module Harnex
193
205
  end
194
206
 
195
207
  def inject_stop
208
+ if adapter.transport == :stdio_jsonrpc
209
+ @inject_mutex.synchronize do
210
+ begin
211
+ adapter.interrupt
212
+ rescue StandardError
213
+ nil
214
+ end
215
+ @state_machine.force_busy!
216
+ end
217
+ return { ok: true, signal: "interrupt_sent" }
218
+ end
219
+
196
220
  raise "session is not running" unless pid && Harnex.alive_pid?(pid)
197
221
 
198
222
  @inject_mutex.synchronize do
@@ -204,6 +228,10 @@ module Harnex
204
228
  end
205
229
 
206
230
  def inject_via_adapter(text:, submit:, enter_only:, force: false)
231
+ if adapter.transport == :stdio_jsonrpc
232
+ return inject_via_jsonrpc(text: text, force: force)
233
+ end
234
+
207
235
  snapshot = adapter.wait_for_sendable(method(:screen_snapshot), submit: submit, enter_only: enter_only, force: force)
208
236
  payload = adapter.build_send_payload(
209
237
  text: text,
@@ -228,8 +256,32 @@ module Harnex
228
256
  .tap { emit_send_event(text, force: payload[:force]) }
229
257
  end
230
258
 
259
+ def inject_via_jsonrpc(text:, force: false)
260
+ turn_id = nil
261
+ @inject_mutex.synchronize do
262
+ turn_id = adapter.dispatch(prompt: text)
263
+ @state_machine.force_busy!
264
+ @injected_count += 1
265
+ @last_injected_at = Time.now
266
+ persist_registry
267
+ end
268
+
269
+ emit_send_event(text, force: force)
270
+ {
271
+ ok: true,
272
+ cli: adapter.key,
273
+ bytes_written: text.to_s.bytesize,
274
+ injected_count: @injected_count,
275
+ newline: false,
276
+ input_state: adapter.input_state(nil),
277
+ force: force,
278
+ turn_id: turn_id
279
+ }
280
+ end
281
+
231
282
  def sync_window_size
232
283
  return unless STDIN.tty?
284
+ return unless @writer
233
285
 
234
286
  @writer.winsize = STDIN.winsize
235
287
  rescue StandardError
@@ -242,6 +294,154 @@ module Harnex
242
294
 
243
295
  private
244
296
 
297
+ def run_jsonrpc
298
+ adapter.on_notification { |msg| handle_rpc_notification(msg) }
299
+ adapter.on_disconnect { |err| handle_rpc_disconnect(err) }
300
+
301
+ adapter.start_rpc(env: child_env, cwd: repo_root)
302
+ @pid = adapter.pid
303
+ emit_started_event
304
+ emit_git_start_event
305
+
306
+ install_signal_handlers
307
+ @server = ApiServer.new(self)
308
+ @server.start
309
+ persist_registry
310
+
311
+ watch_thread = start_watch_thread
312
+ @inbox.start
313
+
314
+ if @pid
315
+ begin
316
+ _, status = Process.wait2(@pid)
317
+ @term_signal = status.signaled? ? status.termsig : nil
318
+ @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
319
+ rescue Errno::ECHILD
320
+ @exit_code = 0
321
+ end
322
+ else
323
+ @rpc_done_lock = Mutex.new
324
+ @rpc_done_cond = ConditionVariable.new
325
+ @rpc_done_lock.synchronize { @rpc_done_cond.wait(@rpc_done_lock) until @rpc_done }
326
+ @exit_code = 0
327
+ end
328
+ @ended_at = Time.now
329
+
330
+ emit_session_end_telemetry
331
+ @exit_reason = classify_exit
332
+ summary_record = build_summary_record
333
+ append_summary_record(summary_record)
334
+ emit_summary_event
335
+ emit_exit_event
336
+ watch_thread&.kill
337
+ @exit_code
338
+ ensure
339
+ @inbox.stop
340
+ @server&.stop
341
+ begin
342
+ adapter.close
343
+ rescue StandardError
344
+ nil
345
+ end
346
+ persist_exit_status
347
+ cleanup_registry
348
+ @output_log&.close unless @output_log&.closed?
349
+ @events_log&.close unless @events_log&.closed?
350
+ end
351
+
352
+ def signal_rpc_done!
353
+ @rpc_done = true
354
+ if defined?(@rpc_done_lock) && @rpc_done_lock
355
+ @rpc_done_lock.synchronize { @rpc_done_cond&.signal }
356
+ end
357
+ end
358
+
359
+ def handle_rpc_notification(message)
360
+ method = message["method"]
361
+ params = message["params"] || {}
362
+
363
+ case method
364
+ when "thread/started"
365
+ @rpc_thread_id = params["threadId"] || params["thread_id"]
366
+ when "turn/started"
367
+ emit_event("turn_started", turnId: params["turnId"] || params["turn_id"])
368
+ when "turn/completed"
369
+ @last_completed_at = Time.now
370
+ payload = { turnId: params["turnId"] || params["turn_id"] }
371
+ payload[:status] = params["status"] if params["status"]
372
+ payload[:tokenUsage] = params["tokenUsage"] if params["tokenUsage"]
373
+ emit_event("task_complete", **payload)
374
+ when "item/completed"
375
+ emit_event("item_completed", item: params["item"])
376
+ text = render_item_text(params["item"])
377
+ record_synthesized(text) if text
378
+ when "thread/compacted"
379
+ emit_event("compaction", **params)
380
+ when "thread/tokenUsage/updated"
381
+ # Surfaced via status fields in Phase 4; no event spam.
382
+ @token_usage = params["usage"] || params
383
+ when "thread/status/changed"
384
+ # State machine reflects RPC state; no event needed.
385
+ nil
386
+ when "account/rateLimits/updated"
387
+ @rate_limits = params
388
+ when "error"
389
+ emit_event("disconnected", source: "error_notification", message: params["message"])
390
+ signal_rpc_done!
391
+ end
392
+ rescue StandardError => e
393
+ warn("harnex: rpc notification handler error: #{e.message}")
394
+ end
395
+
396
+ def handle_rpc_disconnect(error)
397
+ msg = error.is_a?(Hash) ? error["message"] : error&.message
398
+ emit_event("disconnected", source: "transport", message: msg) rescue nil
399
+ signal_rpc_done!
400
+ end
401
+
402
+ def render_item_text(item)
403
+ return nil unless item.is_a?(Hash)
404
+
405
+ type = item["type"] || item["kind"]
406
+ case type
407
+ when "agent_message", "assistant_message"
408
+ item["text"] || item.dig("message", "text")
409
+ when "tool_call"
410
+ name = item["name"] || item.dig("tool", "name") || "tool"
411
+ params = item["params"] || item["arguments"]
412
+ "tool: #{name}#{params ? " #{summarize(params)}" : ""}"
413
+ else
414
+ item["text"]
415
+ end
416
+ end
417
+
418
+ def summarize(value)
419
+ str = value.is_a?(String) ? value : JSON.generate(value)
420
+ str.length > 120 ? "#{str[0, 117]}..." : str
421
+ rescue StandardError
422
+ ""
423
+ end
424
+
425
+ def record_synthesized(text)
426
+ return if text.nil? || text.to_s.empty?
427
+
428
+ payload = text.to_s.dup
429
+ payload << "\n" unless payload.end_with?("\n")
430
+ bytes = payload.b
431
+ @mutex.synchronize do
432
+ append_output_log(bytes)
433
+ @output_buffer << bytes
434
+ overflow = @output_buffer.bytesize - OUTPUT_BUFFER_LIMIT
435
+ @output_buffer = @output_buffer.byteslice(overflow, OUTPUT_BUFFER_LIMIT) if overflow.positive?
436
+ end
437
+ begin
438
+ STDOUT.write(payload)
439
+ STDOUT.flush
440
+ rescue StandardError
441
+ nil
442
+ end
443
+ end
444
+
245
445
  def child_env
246
446
  env = {
247
447
  "HARNEX_SESSION_ID" => session_id,
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.5.0"
3
- RELEASE_DATE = "2026-05-01"
2
+ VERSION = "0.6.0"
3
+ RELEASE_DATE = "2026-05-06"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -25,4 +25,5 @@ require_relative "harnex/commands/pane"
25
25
  require_relative "harnex/commands/recipes"
26
26
  require_relative "harnex/commands/guide"
27
27
  require_relative "harnex/commands/skills"
28
+ require_relative "harnex/commands/doctor"
28
29
  require_relative "harnex/cli"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: harnex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-01 00:00:00.000000000 Z
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
14
14
  adds a control plane for discovery, messaging, and coordination.
@@ -19,6 +19,7 @@ executables:
19
19
  extensions: []
20
20
  extra_rdoc_files: []
21
21
  files:
22
+ - CHANGELOG.md
22
23
  - GUIDE.md
23
24
  - LICENSE
24
25
  - README.md
@@ -30,8 +31,10 @@ files:
30
31
  - lib/harnex/adapters/base.rb
31
32
  - lib/harnex/adapters/claude.rb
32
33
  - lib/harnex/adapters/codex.rb
34
+ - lib/harnex/adapters/codex_appserver.rb
33
35
  - lib/harnex/adapters/generic.rb
34
36
  - lib/harnex/cli.rb
37
+ - lib/harnex/commands/doctor.rb
35
38
  - lib/harnex/commands/events.rb
36
39
  - lib/harnex/commands/guide.rb
37
40
  - lib/harnex/commands/logs.rb