harnex 0.4.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 +4 -4
- data/CHANGELOG.md +39 -0
- data/TECHNICAL.md +19 -1
- data/lib/harnex/adapters/base.rb +14 -0
- data/lib/harnex/adapters/codex.rb +36 -0
- data/lib/harnex/adapters/codex_appserver.rb +390 -0
- data/lib/harnex/adapters.rb +16 -3
- data/lib/harnex/cli.rb +3 -0
- data/lib/harnex/commands/doctor.rb +75 -0
- data/lib/harnex/commands/run.rb +51 -7
- data/lib/harnex/commands/wait.rb +109 -3
- data/lib/harnex/core.rb +78 -2
- data/lib/harnex/runtime/session.rb +408 -4
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0003e780e17784a566b09e37d39ca760531d78ef8b912093944bb7f1eb65c134
|
|
4
|
+
data.tar.gz: 58f1ec4d5919c19a7b445900aff28143636a530470e7d04bf1962532661d308e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/harnex/adapters/base.rb
CHANGED
|
@@ -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
|
|
@@ -39,6 +49,10 @@ module Harnex
|
|
|
39
49
|
}
|
|
40
50
|
end
|
|
41
51
|
|
|
52
|
+
def parse_session_summary(_transcript_tail)
|
|
53
|
+
{}
|
|
54
|
+
end
|
|
55
|
+
|
|
42
56
|
def send_wait_seconds(submit:, enter_only:)
|
|
43
57
|
0.0
|
|
44
58
|
end
|
|
@@ -56,6 +56,25 @@ module Harnex
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
def parse_session_summary(transcript_tail)
|
|
60
|
+
summary = empty_session_summary
|
|
61
|
+
text = transcript_tail.to_s
|
|
62
|
+
|
|
63
|
+
if (match = text.match(/Token usage:\s+total=([\d,]+)\s+input=([\d,]+)(?:\s+\(\+\s+([\d,]+)\s+cached\))?\s+output=([\d,]+)(?:\s+\(reasoning\s+([\d,]+)\))?/))
|
|
64
|
+
summary[:total_tokens] = parse_token_count(match[1])
|
|
65
|
+
summary[:input_tokens] = parse_token_count(match[2])
|
|
66
|
+
summary[:cached_tokens] = parse_token_count(match[3])
|
|
67
|
+
summary[:output_tokens] = parse_token_count(match[4])
|
|
68
|
+
summary[:reasoning_tokens] = parse_token_count(match[5])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if (match = text.match(/codex resume\s+([0-9a-f-]{36})/))
|
|
72
|
+
summary[:agent_session_id] = match[1]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
summary
|
|
76
|
+
end
|
|
77
|
+
|
|
59
78
|
def send_wait_seconds(submit:, enter_only:)
|
|
60
79
|
return 0.0 unless submit
|
|
61
80
|
return 0.0 if enter_only
|
|
@@ -101,6 +120,23 @@ module Harnex
|
|
|
101
120
|
|
|
102
121
|
protected
|
|
103
122
|
|
|
123
|
+
def empty_session_summary
|
|
124
|
+
{
|
|
125
|
+
input_tokens: nil,
|
|
126
|
+
output_tokens: nil,
|
|
127
|
+
reasoning_tokens: nil,
|
|
128
|
+
cached_tokens: nil,
|
|
129
|
+
total_tokens: nil,
|
|
130
|
+
agent_session_id: nil
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_token_count(value)
|
|
135
|
+
return nil if value.nil?
|
|
136
|
+
|
|
137
|
+
Integer(value.delete(","))
|
|
138
|
+
end
|
|
139
|
+
|
|
104
140
|
def submit_delay_ms(text)
|
|
105
141
|
extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
|
|
106
142
|
SUBMIT_DELAY_MS + extra
|
|
@@ -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
|
data/lib/harnex/adapters.rb
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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(
|
|
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
|