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.
@@ -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
- base_command + cli_extra_args
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 extension_error
84
- extension_ui_request
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: { prompt: text.to_s },
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
- @pid, @write_io, @read_io = spawn_subprocess(env, cwd)
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["model"] = model if model
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
- @state = data["isStreaming"] ? :busy : :prompt
379
- @state = :busy if data["isCompacting"]
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 "pi rpc disconnected" if @state == :disconnected
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("pi rpc disconnected")
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, _stderr_io, wait_thr = Open3.popen3(spawn_env, *build_command, **opts)
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's adapter dependencies.
13
- Currently verifies that Codex CLI is installed and at version
14
- >= #{MIN_CODEX_VERSION} (required for the JSON-RPC `app-server`
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 --sweep
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 Codex CLI.
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 = [check_codex]
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, $?]
@@ -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
- Session.validate_binary!(adapter.build_command)
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
- Session.validate_binary!(adapter.build_command)
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