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 +4 -4
- data/CHANGELOG.md +39 -0
- data/TECHNICAL.md +19 -1
- data/lib/harnex/adapters/base.rb +10 -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 +11 -4
- data/lib/harnex/commands/wait.rb +109 -3
- data/lib/harnex/core.rb +2 -2
- data/lib/harnex/runtime/session.rb +201 -1
- 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
|
|
@@ -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
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -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
|
data/lib/harnex/commands/wait.rb
CHANGED
|
@@ -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
|
|
17
|
-
|
|
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
|
-
|
|
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,
|
data/lib/harnex/version.rb
CHANGED
data/lib/harnex.rb
CHANGED
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.
|
|
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-
|
|
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
|