harnex 0.10.2 → 0.12.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 +63 -0
- data/README.md +22 -7
- data/TECHNICAL.md +25 -0
- data/docs/pi-rpc.md +151 -0
- data/guides/01_dispatch.md +26 -2
- data/guides/04_monitoring.md +32 -8
- data/lib/harnex/adapters/pi.rb +220 -21
- data/lib/harnex/commands/doctor.rb +57 -9
- data/lib/harnex/commands/run.rb +34 -6
- data/lib/harnex/commands/status.rb +16 -2
- data/lib/harnex/core.rb +15 -0
- data/lib/harnex/runtime/completion_notifier.rb +137 -0
- data/lib/harnex/runtime/session.rb +232 -21
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +1 -0
- metadata +4 -2
data/lib/harnex/adapters/pi.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "open3"
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
require "timeout"
|
|
3
5
|
|
|
4
6
|
module Harnex
|
|
5
7
|
module Adapters
|
|
@@ -9,7 +11,11 @@ module Harnex
|
|
|
9
11
|
class Pi < Base
|
|
10
12
|
STOP_TERM_GRACE_SECONDS = 0.5
|
|
11
13
|
STOP_KILL_GRACE_SECONDS = 1.0
|
|
14
|
+
REQUEST_TIMEOUT_SECONDS = 30.0
|
|
15
|
+
STDERR_TAIL_BYTES = 16 * 1024
|
|
16
|
+
MIN_RPC_VERSION = Gem::Version.new("0.80.4")
|
|
12
17
|
DIALOG_UI_METHODS = %w[select confirm input editor].freeze
|
|
18
|
+
THINKING_LEVELS = %w[off minimal low medium high xhigh max].freeze
|
|
13
19
|
|
|
14
20
|
attr_reader :initial_prompt, :last_completed_at
|
|
15
21
|
|
|
@@ -20,8 +26,14 @@ module Harnex
|
|
|
20
26
|
@disconnect_handler = nil
|
|
21
27
|
@read_io = nil
|
|
22
28
|
@write_io = nil
|
|
29
|
+
@stderr_io = nil
|
|
23
30
|
@pid = nil
|
|
31
|
+
@wait_thr = nil
|
|
24
32
|
@reader_thread = nil
|
|
33
|
+
@stderr_thread = nil
|
|
34
|
+
@stderr_mutex = Mutex.new
|
|
35
|
+
@stderr_tail = +""
|
|
36
|
+
@stderr_tail.force_encoding(Encoding::BINARY)
|
|
25
37
|
@closed = false
|
|
26
38
|
@disconnect_signaled = false
|
|
27
39
|
@state = :disconnected
|
|
@@ -37,6 +49,8 @@ module Harnex
|
|
|
37
49
|
)
|
|
38
50
|
@model = nil
|
|
39
51
|
@provider = nil
|
|
52
|
+
@startup_model = nil
|
|
53
|
+
@startup_effort = nil
|
|
40
54
|
@session_stats_requested = false
|
|
41
55
|
@last_completed_at = nil
|
|
42
56
|
end
|
|
@@ -70,18 +84,53 @@ module Harnex
|
|
|
70
84
|
end
|
|
71
85
|
|
|
72
86
|
def build_command
|
|
73
|
-
|
|
87
|
+
args = cli_extra_args
|
|
88
|
+
args += ["--model", @startup_model] if @startup_model
|
|
89
|
+
args += ["--thinking", @startup_effort] if @startup_effort
|
|
90
|
+
base_command + args
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def configure_startup(model: nil, effort: nil)
|
|
94
|
+
requested_model = model.to_s.strip
|
|
95
|
+
requested_effort = effort.to_s.strip
|
|
96
|
+
unless requested_effort.empty? || THINKING_LEVELS.include?(requested_effort)
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"unsupported Pi thinking level #{requested_effort.inspect}; expected one of #{THINKING_LEVELS.join(', ')}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@startup_model = requested_model.empty? ? nil : requested_model
|
|
102
|
+
@startup_effort = requested_effort.empty? ? nil : requested_effort
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_runtime!
|
|
107
|
+
version = parsed_agent_version
|
|
108
|
+
if version.nil?
|
|
109
|
+
raise "could not determine Pi version from `pi --version`; Pi RPC requires >= #{MIN_RPC_VERSION}"
|
|
110
|
+
end
|
|
111
|
+
return true if version >= MIN_RPC_VERSION
|
|
112
|
+
|
|
113
|
+
raise "Pi #{version} is too old for reliable RPC settlement; harnex requires Pi >= #{MIN_RPC_VERSION} (run `pi update`)"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parsed_agent_version
|
|
117
|
+
match = agent_version.to_s.match(/(\d+\.\d+\.\d+)/)
|
|
118
|
+
match ? Gem::Version.new(match[1]) : nil
|
|
119
|
+
rescue ArgumentError
|
|
120
|
+
nil
|
|
74
121
|
end
|
|
75
122
|
|
|
76
123
|
def describe
|
|
77
124
|
{
|
|
78
125
|
transport: transport,
|
|
79
126
|
protocol: "jsonl",
|
|
127
|
+
minimum_version: MIN_RPC_VERSION.to_s,
|
|
80
128
|
events: %w[
|
|
81
|
-
agent_start agent_end turn_start turn_end message_start message_update message_end
|
|
129
|
+
agent_start agent_end agent_settled turn_start turn_end message_start message_update message_end
|
|
82
130
|
tool_execution_start tool_execution_update tool_execution_end queue_update
|
|
83
|
-
compaction_start compaction_end auto_retry_start auto_retry_end
|
|
84
|
-
|
|
131
|
+
compaction_start compaction_end auto_retry_start auto_retry_end
|
|
132
|
+
summarization_retry_scheduled summarization_retry_attempt_start summarization_retry_finished
|
|
133
|
+
bash_execution_update extension_error extension_ui_request
|
|
85
134
|
]
|
|
86
135
|
}
|
|
87
136
|
end
|
|
@@ -106,8 +155,10 @@ module Harnex
|
|
|
106
155
|
raise ArgumentError, "Pi RPC cannot stage input without submitting it" unless submit || enter_only
|
|
107
156
|
raise ArgumentError, "Pi RPC does not support submit-only input" if enter_only
|
|
108
157
|
|
|
158
|
+
dispatch = { prompt: text.to_s }
|
|
159
|
+
dispatch[:streaming_behavior] = "steer" if force && state[:state] == "busy"
|
|
109
160
|
{
|
|
110
|
-
dispatch:
|
|
161
|
+
dispatch: dispatch,
|
|
111
162
|
input_state: state,
|
|
112
163
|
force: force
|
|
113
164
|
}
|
|
@@ -131,24 +182,24 @@ module Harnex
|
|
|
131
182
|
@write_io = write_io
|
|
132
183
|
@pid = pid
|
|
133
184
|
else
|
|
134
|
-
|
|
185
|
+
validate_runtime!
|
|
186
|
+
@pid, @write_io, @read_io, @stderr_io, @wait_thr = spawn_subprocess(env, cwd)
|
|
135
187
|
end
|
|
136
188
|
|
|
137
189
|
@closed = false
|
|
138
190
|
@disconnect_signaled = false
|
|
139
191
|
@state = :prompt
|
|
192
|
+
@stderr_thread = Thread.new { drain_stderr } if @stderr_io
|
|
140
193
|
@reader_thread = Thread.new { read_loop }
|
|
141
194
|
request_state_async
|
|
142
195
|
self
|
|
143
196
|
end
|
|
144
197
|
|
|
145
|
-
def dispatch(prompt:, model: nil, effort: nil)
|
|
198
|
+
def dispatch(prompt:, model: nil, effort: nil, streaming_behavior: nil)
|
|
146
199
|
ensure_open!
|
|
147
|
-
|
|
200
|
+
apply_dispatch_overrides(model: model, effort: effort) unless @state == :busy
|
|
148
201
|
payload = { "type" => "prompt", "message" => prompt.to_s }
|
|
149
|
-
payload["
|
|
150
|
-
payload["thinkingLevel"] = effort if effort
|
|
151
|
-
|
|
202
|
+
payload["streamingBehavior"] = streaming_behavior if streaming_behavior
|
|
152
203
|
request(payload)
|
|
153
204
|
@state = :busy
|
|
154
205
|
nil
|
|
@@ -156,7 +207,7 @@ module Harnex
|
|
|
156
207
|
|
|
157
208
|
def interrupt(turn_id: nil)
|
|
158
209
|
ensure_open!
|
|
159
|
-
request("type" => "abort")
|
|
210
|
+
request({ "type" => "abort" })
|
|
160
211
|
rescue StandardError
|
|
161
212
|
nil
|
|
162
213
|
end
|
|
@@ -219,6 +270,7 @@ module Harnex
|
|
|
219
270
|
|
|
220
271
|
begin
|
|
221
272
|
@write_io.close unless @write_io&.closed?
|
|
273
|
+
@read_io.close if !@pid && !@wait_thr && @read_io && !@read_io.closed?
|
|
222
274
|
rescue IOError
|
|
223
275
|
nil
|
|
224
276
|
end
|
|
@@ -229,6 +281,13 @@ module Harnex
|
|
|
229
281
|
term_grace_seconds: STOP_TERM_GRACE_SECONDS,
|
|
230
282
|
kill_grace_seconds: STOP_KILL_GRACE_SECONDS
|
|
231
283
|
)
|
|
284
|
+
@reader_thread&.join(1)
|
|
285
|
+
@stderr_thread&.join(1)
|
|
286
|
+
[@read_io, @stderr_io].compact.each do |io|
|
|
287
|
+
io.close unless io.closed?
|
|
288
|
+
rescue IOError
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
232
291
|
end
|
|
233
292
|
|
|
234
293
|
def terminate_subprocess(term_grace_seconds: STOP_TERM_GRACE_SECONDS, kill_grace_seconds: STOP_KILL_GRACE_SECONDS)
|
|
@@ -255,9 +314,36 @@ module Harnex
|
|
|
255
314
|
@pid
|
|
256
315
|
end
|
|
257
316
|
|
|
317
|
+
def wait_for_exit
|
|
318
|
+
return nil unless @wait_thr || @pid
|
|
319
|
+
|
|
320
|
+
status = if @wait_thr
|
|
321
|
+
@wait_thr.value
|
|
322
|
+
else
|
|
323
|
+
_waited_pid, process_status = Process.wait2(@pid)
|
|
324
|
+
process_status
|
|
325
|
+
end
|
|
326
|
+
@pid = nil
|
|
327
|
+
status
|
|
328
|
+
rescue Errno::ECHILD
|
|
329
|
+
@pid = nil
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def stderr_tail
|
|
334
|
+
@stderr_mutex.synchronize { @stderr_tail.dup.force_encoding(Encoding::UTF_8).scrub("") }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def disconnect_diagnostic
|
|
338
|
+
tail = stderr_tail.strip
|
|
339
|
+
return "pi rpc disconnected" if tail.empty?
|
|
340
|
+
|
|
341
|
+
"pi rpc disconnected; stderr: #{tail}"
|
|
342
|
+
end
|
|
343
|
+
|
|
258
344
|
private
|
|
259
345
|
|
|
260
|
-
def request(payload)
|
|
346
|
+
def request(payload, timeout: REQUEST_TIMEOUT_SECONDS)
|
|
261
347
|
raise "pi rpc client is closed" if @closed
|
|
262
348
|
|
|
263
349
|
queue = Queue.new
|
|
@@ -269,7 +355,7 @@ module Harnex
|
|
|
269
355
|
end
|
|
270
356
|
|
|
271
357
|
write_line(payload.merge("id" => id))
|
|
272
|
-
response = queue.pop
|
|
358
|
+
response = Timeout.timeout(timeout.to_f) { queue.pop }
|
|
273
359
|
raise response if response.is_a?(Exception)
|
|
274
360
|
|
|
275
361
|
unless response["success"]
|
|
@@ -278,6 +364,93 @@ module Harnex
|
|
|
278
364
|
|
|
279
365
|
handle_response_data(response)
|
|
280
366
|
response["data"] || {}
|
|
367
|
+
rescue Timeout::Error
|
|
368
|
+
@id_mutex.synchronize { @pending.delete(id) if defined?(id) && id }
|
|
369
|
+
error = Timeout::Error.new("pi rpc #{payload["type"]} timed out after #{timeout}s")
|
|
370
|
+
signal_disconnect(error)
|
|
371
|
+
raise error
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def apply_dispatch_overrides(model:, effort:)
|
|
375
|
+
requested_model = model.to_s.strip
|
|
376
|
+
requested_effort = effort.to_s.strip
|
|
377
|
+
if startup_controls_match?(requested_model, requested_effort)
|
|
378
|
+
validate_startup_controls!(
|
|
379
|
+
request({ "type" => "get_state" }),
|
|
380
|
+
model: requested_model,
|
|
381
|
+
effort: requested_effort
|
|
382
|
+
)
|
|
383
|
+
return
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
unless requested_model.empty?
|
|
387
|
+
provider, model_id = resolve_model_reference(requested_model)
|
|
388
|
+
selected = request({
|
|
389
|
+
"type" => "set_model",
|
|
390
|
+
"provider" => provider,
|
|
391
|
+
"modelId" => model_id
|
|
392
|
+
})
|
|
393
|
+
absorb_model(selected)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
return if requested_effort.empty?
|
|
397
|
+
|
|
398
|
+
unless THINKING_LEVELS.include?(requested_effort)
|
|
399
|
+
raise ArgumentError,
|
|
400
|
+
"unsupported Pi thinking level #{requested_effort.inspect}; expected one of #{THINKING_LEVELS.join(', ')}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
request({ "type" => "set_thinking_level", "level" => requested_effort })
|
|
404
|
+
state = request({ "type" => "get_state" })
|
|
405
|
+
effective = state["thinkingLevel"].to_s
|
|
406
|
+
return if effective == requested_effort
|
|
407
|
+
|
|
408
|
+
raise ArgumentError,
|
|
409
|
+
"Pi could not apply thinking level #{requested_effort.inspect}; effective level is #{effective.empty? ? 'unknown' : effective.inspect}"
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def startup_controls_match?(model, effort)
|
|
413
|
+
has_startup_control = @startup_model || @startup_effort
|
|
414
|
+
has_startup_control && model == @startup_model.to_s && effort == @startup_effort.to_s
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def validate_startup_controls!(state, model:, effort:)
|
|
418
|
+
effective_model = state["model"]
|
|
419
|
+
if !model.empty? && effective_model.is_a?(Hash)
|
|
420
|
+
expected_provider, expected_id = model.include?("/") ? model.split("/", 2) : [nil, model]
|
|
421
|
+
actual_provider = effective_model["provider"].to_s
|
|
422
|
+
actual_id = effective_model["id"].to_s
|
|
423
|
+
model_matches = actual_id == expected_id && (expected_provider.nil? || actual_provider == expected_provider)
|
|
424
|
+
unless model_matches
|
|
425
|
+
raise ArgumentError,
|
|
426
|
+
"Pi could not apply model #{model.inspect}; effective model is #{actual_provider}/#{actual_id}"
|
|
427
|
+
end
|
|
428
|
+
elsif !model.empty?
|
|
429
|
+
raise ArgumentError, "Pi could not report the effective model for requested #{model.inspect}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
return if effort.empty? || state["thinkingLevel"].to_s == effort
|
|
433
|
+
|
|
434
|
+
effective = state["thinkingLevel"].to_s
|
|
435
|
+
raise ArgumentError,
|
|
436
|
+
"Pi could not apply thinking level #{effort.inspect}; effective level is #{effective.empty? ? 'unknown' : effective.inspect}"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def resolve_model_reference(model)
|
|
440
|
+
requested = model.to_s.strip
|
|
441
|
+
if requested.include?("/")
|
|
442
|
+
provider, model_id = requested.split("/", 2)
|
|
443
|
+
return [provider, model_id] unless provider.empty? || model_id.empty?
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
data = request({ "type" => "get_available_models" })
|
|
447
|
+
matches = Array(data["models"]).select do |candidate|
|
|
448
|
+
candidate.is_a?(Hash) && candidate["id"].to_s == requested
|
|
449
|
+
end
|
|
450
|
+
return [matches.first["provider"], matches.first["id"]] if matches.length == 1
|
|
451
|
+
|
|
452
|
+
detail = matches.empty? ? "was not found" : "is ambiguous across #{matches.map { |m| m["provider"] }.uniq.join(', ')}"
|
|
453
|
+
raise ArgumentError, "Pi model #{requested.inspect} #{detail}; use provider/model"
|
|
281
454
|
end
|
|
282
455
|
|
|
283
456
|
def request_state_async
|
|
@@ -287,7 +460,7 @@ module Harnex
|
|
|
287
460
|
end
|
|
288
461
|
|
|
289
462
|
def attempt_live_summary_refresh
|
|
290
|
-
request("type" => "get_session_stats")
|
|
463
|
+
request({ "type" => "get_session_stats" })
|
|
291
464
|
rescue StandardError
|
|
292
465
|
nil
|
|
293
466
|
end
|
|
@@ -350,6 +523,8 @@ module Harnex
|
|
|
350
523
|
absorb_state_data(message["data"])
|
|
351
524
|
when "get_session_stats"
|
|
352
525
|
absorb_session_stats(message["data"])
|
|
526
|
+
when "set_model"
|
|
527
|
+
absorb_model(message["data"])
|
|
353
528
|
end
|
|
354
529
|
end
|
|
355
530
|
|
|
@@ -358,6 +533,11 @@ module Harnex
|
|
|
358
533
|
when "agent_start", "turn_start"
|
|
359
534
|
@state = :busy
|
|
360
535
|
when "agent_end"
|
|
536
|
+
# agent_end is a low-level run boundary. Pi may still retry, compact,
|
|
537
|
+
# or process queued continuations, so only agent_settled is idle.
|
|
538
|
+
@state = :busy
|
|
539
|
+
request_session_stats_async
|
|
540
|
+
when "agent_settled"
|
|
361
541
|
@state = :prompt
|
|
362
542
|
@last_completed_at = Time.now
|
|
363
543
|
request_session_stats_async
|
|
@@ -375,8 +555,14 @@ module Harnex
|
|
|
375
555
|
def absorb_state_data(data)
|
|
376
556
|
return unless data.is_a?(Hash)
|
|
377
557
|
|
|
378
|
-
|
|
379
|
-
|
|
558
|
+
observed_busy = data["isStreaming"] || data["isCompacting"]
|
|
559
|
+
if observed_busy
|
|
560
|
+
@state = :busy
|
|
561
|
+
elsif @state != :busy
|
|
562
|
+
# An earlier get_state response may arrive after a prompt was accepted.
|
|
563
|
+
# Never let that stale idle snapshot downgrade an event-proven busy state.
|
|
564
|
+
@state = :prompt
|
|
565
|
+
end
|
|
380
566
|
@summary_mutex.synchronize do
|
|
381
567
|
@session_summary[:agent_session_id] = data["sessionId"] if data["sessionId"]
|
|
382
568
|
end
|
|
@@ -474,7 +660,7 @@ module Harnex
|
|
|
474
660
|
|
|
475
661
|
def ensure_open!
|
|
476
662
|
raise "pi rpc client not started" unless @read_io && @write_io
|
|
477
|
-
raise
|
|
663
|
+
raise disconnect_diagnostic if @state == :disconnected
|
|
478
664
|
end
|
|
479
665
|
|
|
480
666
|
def connected?
|
|
@@ -494,7 +680,7 @@ module Harnex
|
|
|
494
680
|
@disconnect_signaled = true
|
|
495
681
|
@state = :disconnected
|
|
496
682
|
fail_pending_requests(
|
|
497
|
-
error.is_a?(Exception) ? error : StandardError.new(
|
|
683
|
+
error.is_a?(Exception) ? error : StandardError.new(disconnect_diagnostic)
|
|
498
684
|
)
|
|
499
685
|
@disconnect_handler&.call(error)
|
|
500
686
|
end
|
|
@@ -535,12 +721,25 @@ module Harnex
|
|
|
535
721
|
@extra_args.reject { |a| a.is_a?(String) && a.start_with?("[harnex session id=") }
|
|
536
722
|
end
|
|
537
723
|
|
|
724
|
+
def drain_stderr
|
|
725
|
+
loop do
|
|
726
|
+
chunk = @stderr_io.readpartial(4096)
|
|
727
|
+
@stderr_mutex.synchronize do
|
|
728
|
+
@stderr_tail << chunk
|
|
729
|
+
overflow = @stderr_tail.bytesize - STDERR_TAIL_BYTES
|
|
730
|
+
@stderr_tail = @stderr_tail.byteslice(overflow, STDERR_TAIL_BYTES) if overflow.positive?
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
rescue EOFError, IOError, Errno::EIO
|
|
734
|
+
nil
|
|
735
|
+
end
|
|
736
|
+
|
|
538
737
|
def spawn_subprocess(env, cwd)
|
|
539
738
|
spawn_env = env || {}
|
|
540
739
|
opts = {}
|
|
541
740
|
opts[:chdir] = cwd if cwd
|
|
542
|
-
stdin_io, stdout_io,
|
|
543
|
-
[wait_thr.pid, stdin_io, stdout_io]
|
|
741
|
+
stdin_io, stdout_io, stderr_io, wait_thr = Open3.popen3(spawn_env, *build_command, **opts)
|
|
742
|
+
[wait_thr.pid, stdin_io, stdout_io, stderr_io, wait_thr]
|
|
544
743
|
end
|
|
545
744
|
|
|
546
745
|
def blocked_message(state, enter_only:)
|
|
@@ -4,17 +4,20 @@ require "optparse"
|
|
|
4
4
|
module Harnex
|
|
5
5
|
class Doctor
|
|
6
6
|
MIN_CODEX_VERSION = Gem::Version.new("0.128.0")
|
|
7
|
+
MIN_PI_VERSION = Harnex::Adapters::Pi::MIN_RPC_VERSION
|
|
8
|
+
SUPPORTED_ADAPTERS = %w[codex pi].freeze
|
|
7
9
|
|
|
8
10
|
def self.usage
|
|
9
11
|
<<~TEXT
|
|
10
|
-
Usage: harnex doctor [--sweep] [--prune [--dry-run]]
|
|
12
|
+
Usage: harnex doctor [--adapter codex|pi|all] [--sweep] [--prune [--dry-run]]
|
|
11
13
|
|
|
12
|
-
Runs preflight checks for harnex
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
adapter).
|
|
14
|
+
Runs static preflight checks for harnex adapter dependencies. Codex is
|
|
15
|
+
checked by default for backwards compatibility; select Pi explicitly
|
|
16
|
+
after installing or upgrading it.
|
|
16
17
|
|
|
17
18
|
Options:
|
|
19
|
+
--adapter NAME
|
|
20
|
+
Check codex, pi, or all (repeatable; default: codex)
|
|
18
21
|
--sweep Include a read-only report of harnex/tmux session drift
|
|
19
22
|
--prune Apply bounded harnex events/output/receipt retention pruning
|
|
20
23
|
--dry-run Preview --prune candidates without deleting
|
|
@@ -22,22 +25,25 @@ module Harnex
|
|
|
22
25
|
|
|
23
26
|
Common patterns:
|
|
24
27
|
harnex doctor
|
|
25
|
-
harnex doctor --
|
|
28
|
+
harnex doctor --adapter pi
|
|
29
|
+
harnex doctor --adapter all --sweep
|
|
26
30
|
harnex doctor --prune --dry-run
|
|
27
31
|
harnex doctor --prune
|
|
28
32
|
harnex doctor --help
|
|
29
33
|
|
|
30
34
|
Gotchas:
|
|
31
35
|
doctor validates local adapter prerequisites; it does not start sessions.
|
|
36
|
+
Pi RPC requires >= #{MIN_PI_VERSION} so `agent_settled` is available.
|
|
32
37
|
--sweep is diagnostic only; it does not stop sessions or remove files.
|
|
33
38
|
--dry-run must be paired with --prune.
|
|
34
|
-
Run it after installing or upgrading
|
|
39
|
+
Run it after installing or upgrading a selected agent CLI.
|
|
35
40
|
TEXT
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def initialize(argv = [])
|
|
39
44
|
@argv = argv.dup
|
|
40
45
|
@options = {
|
|
46
|
+
adapters: [],
|
|
41
47
|
sweep: false,
|
|
42
48
|
prune: false,
|
|
43
49
|
dry_run: false,
|
|
@@ -53,7 +59,7 @@ module Harnex
|
|
|
53
59
|
return 0
|
|
54
60
|
end
|
|
55
61
|
|
|
56
|
-
checks =
|
|
62
|
+
checks = selected_adapters.map { |name| name == "pi" ? check_pi : check_codex }
|
|
57
63
|
retention = retention_payload
|
|
58
64
|
summary = {
|
|
59
65
|
ok: checks.all? { |c| c[:ok] } && retention.fetch(:ok, true),
|
|
@@ -69,7 +75,8 @@ module Harnex
|
|
|
69
75
|
|
|
70
76
|
def parser
|
|
71
77
|
@parser ||= OptionParser.new do |opts|
|
|
72
|
-
opts.banner = "Usage: harnex doctor [--sweep] [--prune [--dry-run]]"
|
|
78
|
+
opts.banner = "Usage: harnex doctor [--adapter codex|pi|all] [--sweep] [--prune [--dry-run]]"
|
|
79
|
+
opts.on("--adapter NAME", "Check codex, pi, or all (repeatable)") { |value| @options[:adapters] << value.to_s.downcase }
|
|
73
80
|
opts.on("--sweep", "Include read-only session drift diagnostics") { @options[:sweep] = true }
|
|
74
81
|
opts.on("--prune", "Apply retention pruning") { @options[:prune] = true }
|
|
75
82
|
opts.on("--dry-run", "Preview --prune candidates without deleting") { @options[:dry_run] = true }
|
|
@@ -79,11 +86,25 @@ module Harnex
|
|
|
79
86
|
|
|
80
87
|
def validate_options!
|
|
81
88
|
return if @options[:help]
|
|
89
|
+
|
|
90
|
+
unknown = @options[:adapters] - SUPPORTED_ADAPTERS - ["all"]
|
|
91
|
+
unless unknown.empty?
|
|
92
|
+
raise OptionParser::InvalidArgument,
|
|
93
|
+
"--adapter must be one of #{(SUPPORTED_ADAPTERS + ["all"]).join(', ')}"
|
|
94
|
+
end
|
|
82
95
|
return unless @options[:dry_run] && !@options[:prune]
|
|
83
96
|
|
|
84
97
|
raise OptionParser::InvalidOption, "--dry-run requires --prune"
|
|
85
98
|
end
|
|
86
99
|
|
|
100
|
+
def selected_adapters
|
|
101
|
+
requested = @options[:adapters]
|
|
102
|
+
return ["codex"] if requested.empty?
|
|
103
|
+
return SUPPORTED_ADAPTERS if requested.include?("all")
|
|
104
|
+
|
|
105
|
+
requested.uniq
|
|
106
|
+
end
|
|
107
|
+
|
|
87
108
|
def retention_payload
|
|
88
109
|
repo_root = Harnex.resolve_repo_root(Dir.pwd)
|
|
89
110
|
if @options[:prune]
|
|
@@ -123,6 +144,33 @@ module Harnex
|
|
|
123
144
|
result.merge(ok: true, found: version.to_s)
|
|
124
145
|
end
|
|
125
146
|
|
|
147
|
+
def check_pi
|
|
148
|
+
result = { name: "pi", required: ">= #{MIN_PI_VERSION}" }
|
|
149
|
+
|
|
150
|
+
version_output, status = capture("pi --version")
|
|
151
|
+
if status.nil?
|
|
152
|
+
return result.merge(ok: false, error: "Pi CLI not found on PATH")
|
|
153
|
+
end
|
|
154
|
+
unless status.success?
|
|
155
|
+
return result.merge(ok: false, error: "pi --version failed: #{version_output.strip}")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
version = parse_version(version_output)
|
|
159
|
+
if version.nil?
|
|
160
|
+
return result.merge(ok: false, found: version_output.strip, error: "could not parse Pi version output")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if version < MIN_PI_VERSION
|
|
164
|
+
return result.merge(
|
|
165
|
+
ok: false,
|
|
166
|
+
found: version.to_s,
|
|
167
|
+
error: "Pi #{version} < required #{MIN_PI_VERSION}; run `pi update` before using the structured adapter"
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
result.merge(ok: true, found: version.to_s)
|
|
172
|
+
end
|
|
173
|
+
|
|
126
174
|
def capture(command)
|
|
127
175
|
output = `#{command} 2>&1`
|
|
128
176
|
[output, $?]
|
data/lib/harnex/commands/run.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Harnex
|
|
|
33
33
|
--id --description --detach --tmux --host --port --watch --watch-file
|
|
34
34
|
--stall-after --max-resumes --preset --context --meta
|
|
35
35
|
--artifact-report --validation-report --cwd --root --timeout --inbox-ttl
|
|
36
|
-
--require-artifact-report --require-attribution --auto-stop --fast --legacy-pty
|
|
36
|
+
--require-artifact-report --require-attribution --auto-stop --on-done --fast --legacy-pty
|
|
37
37
|
--allow-live-parent --help
|
|
38
38
|
].concat(TELEMETRY_FLAGS.keys).freeze
|
|
39
39
|
|
|
@@ -43,7 +43,7 @@ module Harnex
|
|
|
43
43
|
VALUE_FLAGS = %w[
|
|
44
44
|
--id --description --host --port --watch --watch-file --stall-after
|
|
45
45
|
--max-resumes --preset --context --meta --artifact-report
|
|
46
|
-
--validation-report --cwd --root --timeout --inbox-ttl
|
|
46
|
+
--validation-report --on-done --cwd --root --timeout --inbox-ttl
|
|
47
47
|
].concat(TELEMETRY_FLAGS.keys).freeze
|
|
48
48
|
|
|
49
49
|
def self.usage(program_name = "harnex run")
|
|
@@ -64,6 +64,7 @@ module Harnex
|
|
|
64
64
|
--watch-file PATH Auto-send a file-change hook on modification
|
|
65
65
|
--context TEXT Inject as the initial prompt (prepends session header)
|
|
66
66
|
--auto-stop Stop after the first accepted task completion from --context
|
|
67
|
+
--on-done CMD Launch /bin/sh -c CMD once at the first typed work result
|
|
67
68
|
--fast (codex only) Use Codex service_tier="fast".
|
|
68
69
|
Default Codex runs force service_tier="flex".
|
|
69
70
|
--meta JSON Attach parsed JSON metadata to the started event
|
|
@@ -124,6 +125,8 @@ module Harnex
|
|
|
124
125
|
Bare `--watch` enables the babysitter.
|
|
125
126
|
--auto-stop requires --context. Structured Codex turns only count as
|
|
126
127
|
accepted completion after command/tool activity or a Git delta.
|
|
128
|
+
--on-done CMD is trusted local shell input. Do not put secrets in CMD;
|
|
129
|
+
command lines may be visible to local process inspection.
|
|
127
130
|
Every dispatch gets a harness-authored receipt. Workers may write only
|
|
128
131
|
optional claims to HARNEX_ARTIFACT_CLAIMS_PATH; claims never accept work.
|
|
129
132
|
Explicit --stall-after/--max-resumes values override --preset defaults.
|
|
@@ -145,6 +148,9 @@ module Harnex
|
|
|
145
148
|
Passing --tmux without --id creates a random harnex session ID.
|
|
146
149
|
--watch is foreground-only; do not combine it with --tmux or --detach.
|
|
147
150
|
Use -- before child CLI flags when a flag could be parsed by harnex.
|
|
151
|
+
Pi RPC requires Pi >= 0.80.4 (`harnex doctor --adapter pi`). Prefer
|
|
152
|
+
provider/model for Harnex --model, and explicitly pass child
|
|
153
|
+
--approve or --no-approve for deterministic project trust.
|
|
148
154
|
Codex JSON-RPC: pass model as `-c model=NAME`, not `-m NAME`. The
|
|
149
155
|
legacy PTY adapter (--legacy-pty) accepts `-m`.
|
|
150
156
|
TEXT
|
|
@@ -174,6 +180,7 @@ module Harnex
|
|
|
174
180
|
cwd: nil,
|
|
175
181
|
root: nil,
|
|
176
182
|
auto_stop: false,
|
|
183
|
+
on_done: nil,
|
|
177
184
|
allow_live_parent: false,
|
|
178
185
|
detach: false,
|
|
179
186
|
tmux: false,
|
|
@@ -230,7 +237,7 @@ module Harnex
|
|
|
230
237
|
end
|
|
231
238
|
|
|
232
239
|
def run_detached(adapter, cli_name, child_args, repo_root)
|
|
233
|
-
|
|
240
|
+
validate_adapter!(adapter)
|
|
234
241
|
|
|
235
242
|
if @options[:tmux]
|
|
236
243
|
run_in_tmux(cli_name, child_args, repo_root)
|
|
@@ -241,7 +248,7 @@ module Harnex
|
|
|
241
248
|
end
|
|
242
249
|
|
|
243
250
|
def run_watch_mode(adapter, repo_root)
|
|
244
|
-
|
|
251
|
+
validate_adapter!(adapter)
|
|
245
252
|
|
|
246
253
|
result = run_headless(adapter, repo_root, emit_payload: false)
|
|
247
254
|
return result[:exit_code] unless result[:ok]
|
|
@@ -264,6 +271,7 @@ module Harnex
|
|
|
264
271
|
tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
|
|
265
272
|
tmux_cmd += ["--context", @options[:context]] if @options[:context]
|
|
266
273
|
tmux_cmd << "--auto-stop" if @options[:auto_stop]
|
|
274
|
+
tmux_cmd += ["--on-done", @options[:on_done]] if @options[:on_done]
|
|
267
275
|
tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
|
|
268
276
|
@options[:telemetry].each do |key, value|
|
|
269
277
|
flag = TELEMETRY_KEYS_TO_FLAGS[key]
|
|
@@ -424,7 +432,21 @@ module Harnex
|
|
|
424
432
|
raise OptionParser::InvalidOption, "harnex run: invalid config #{e.message}"
|
|
425
433
|
end
|
|
426
434
|
|
|
435
|
+
def validate_adapter!(adapter)
|
|
436
|
+
configure_adapter_startup!(adapter)
|
|
437
|
+
Session.validate_binary!(adapter.build_command)
|
|
438
|
+
adapter.validate_runtime! if adapter.respond_to?(:validate_runtime!)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def configure_adapter_startup!(adapter)
|
|
442
|
+
return unless adapter.respond_to?(:configure_startup)
|
|
443
|
+
|
|
444
|
+
metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
|
|
445
|
+
adapter.configure_startup(model: metadata["model"], effort: metadata["effort"])
|
|
446
|
+
end
|
|
447
|
+
|
|
427
448
|
def build_session(adapter, repo_root)
|
|
449
|
+
configure_adapter_startup!(adapter)
|
|
428
450
|
watch = Harnex.build_watch_config(@options[:watch], repo_root)
|
|
429
451
|
Session.new(
|
|
430
452
|
adapter: adapter,
|
|
@@ -440,6 +462,7 @@ module Harnex
|
|
|
440
462
|
require_artifact_report: @options[:require_artifact_report],
|
|
441
463
|
inbox_ttl: @options[:inbox_ttl],
|
|
442
464
|
auto_stop: @options[:auto_stop],
|
|
465
|
+
on_done: @options[:on_done],
|
|
443
466
|
launch_cwd: history_cwd,
|
|
444
467
|
child_cwd: session_child_cwd
|
|
445
468
|
)
|
|
@@ -607,6 +630,11 @@ module Harnex
|
|
|
607
630
|
@options[:context] = required_option_value("--context", Regexp.last_match(1))
|
|
608
631
|
when "--auto-stop"
|
|
609
632
|
@options[:auto_stop] = true
|
|
633
|
+
when "--on-done"
|
|
634
|
+
index += 1
|
|
635
|
+
@options[:on_done] = required_option_value(arg, argv[index])
|
|
636
|
+
when /\A--on-done=(.+)\z/
|
|
637
|
+
@options[:on_done] = required_option_value("--on-done", Regexp.last_match(1))
|
|
610
638
|
when "--allow-live-parent"
|
|
611
639
|
@options[:allow_live_parent] = true
|
|
612
640
|
when "--require-attribution"
|
|
@@ -715,7 +743,7 @@ module Harnex
|
|
|
715
743
|
nil
|
|
716
744
|
when *VALUE_FLAGS
|
|
717
745
|
index += 1
|
|
718
|
-
when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|artifact-report|validation-report|cwd|root|timeout|inbox-ttl)=/
|
|
746
|
+
when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|artifact-report|validation-report|on-done|cwd|root|timeout|inbox-ttl)=/
|
|
719
747
|
nil
|
|
720
748
|
when telemetry_equals_regex
|
|
721
749
|
nil
|
|
@@ -736,7 +764,7 @@ module Harnex
|
|
|
736
764
|
arg.start_with?(
|
|
737
765
|
"--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
|
|
738
766
|
"--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=",
|
|
739
|
-
"--artifact-report=", "--validation-report=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl=",
|
|
767
|
+
"--artifact-report=", "--validation-report=", "--on-done=", "--cwd=", "--root=", "--timeout=", "--inbox-ttl=",
|
|
740
768
|
*TELEMETRY_EQUALS_PREFIXES
|
|
741
769
|
)
|
|
742
770
|
end
|