harnex 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25ba2b12b69cc6351e2ca4ee1e72e5f378a3828f7486498a2e2a026cf9da771b
4
- data.tar.gz: 7e3cc95587c16dcb0a7b9a3e8339546a9ffbe085e4517407f1325e87fd2f45ce
3
+ metadata.gz: bcf0c88485eceb630094f32cd520c12f6c3d5915f2e2b7790191c2ed98a236a5
4
+ data.tar.gz: f228c15c98a47d69b5b4c7129373f88d5eb42f6413ac998995294ef3271d99e3
5
5
  SHA512:
6
- metadata.gz: fcaa6d99632c375c1315bfc553b99ccf5c583b58a52e627e0af951493481bf33af9b5de50ca4704a1e6e2d7d9a8aa6999b42d94347ff942b76ac5033eb870dae
7
- data.tar.gz: 929011b78aa399cc098b644f0bb2a0995222df71698e450e7ab4785e1be9249e07e4d40225fbf80e02ea9a11f6fefd7913c27682110944ccadacea8f879e7200
6
+ metadata.gz: e3e6e7d4fe9b88ace781c9c9bc587316fce5e405063e6a575ddf5f46b4b76f6a05f7892e36656b7ef0b9160a5ad6890c1af5fe8e83ecd993c14d229c9a7d1468
7
+ data.tar.gz: c043e50074d2100651cbc22585f7d046f5621c51a5ea6d0433d24200f174df98c7fdfd4ea1ec46633cfeae496776c82b08e177abdae7abe547d2244da4f66be0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0] - 2026-08-14 | 01:03 PM | IST
4
+
5
+ ### Fixed
6
+
7
+ - Pi RPC completion now fences on Pi 0.80.4+'s `agent_settled` event instead of
8
+ the lower-level `agent_end`. Provider retries, compaction recovery, and queued
9
+ continuations can no longer publish premature `task_complete` or open the
10
+ inbox early. Final `error`, `aborted`, and `length` stop reasons fail closed;
11
+ a settled run without an authoritative final assistant reason also fails.
12
+ - Pi 0.84's breaking delta-only `message_update` shape is correlated from
13
+ `message_start` through authoritative `message_end`, preserving streamed
14
+ output without duplicating the final message.
15
+ - Harnex `--model` and `--effort` now become supported Pi `--model` /
16
+ `--thinking` startup controls and are verified through RPC `get_state`,
17
+ instead of adding ignored fields to `prompt` or persisting RPC setters into
18
+ the user's Pi defaults. Model mismatches and clamped effort requests fail
19
+ before prompting, telemetry distinguishes requested from observed effective
20
+ model values, and forced busy sends use Pi's `steer` streaming behavior.
21
+ - Pi RPC request waits are bounded, subprocess stderr is continuously drained
22
+ into a bounded diagnostic tail, and process status is read from Open3's wait
23
+ thread instead of racing it with a second `waitpid` and potentially losing a
24
+ nonzero exit status.
25
+
26
+ ### Changed
27
+
28
+ - **Compatibility:** structured Pi dispatch now requires Pi >= 0.80.4. Both
29
+ foreground and detached/tmux launches validate the installed version before
30
+ spawn. This is the first Pi release with the final `agent_settled` fence.
31
+ - Pi retry/compaction events retain settlement state and expose current
32
+ summarization-retry lifecycle events in Harnex's event stream.
33
+ - Codex schema fixtures are refreshed against CLI 0.147.0 after a bounded
34
+ semantic review. The drift is limited to a string path-alias replacement and
35
+ additive optional thread/item fields that Harnex does not consume; existing
36
+ minimal response fixtures still validate, so no Codex adapter behavior
37
+ changes.
38
+
39
+ ### Added
40
+
41
+ - `harnex doctor --adapter pi` statically verifies the Pi RPC version;
42
+ `--adapter all` checks both Pi and Codex while the no-argument default remains
43
+ Codex-compatible.
44
+ - `docs/pi-rpc.md` documents transport semantics, project trust, lifecycle,
45
+ model controls, telemetry, and known boundaries. An opt-in live contract test
46
+ (`PI_INTEGRATION=1`) verifies the installed Pi event shape without adding
47
+ provider cost to the normal suite.
48
+
3
49
  ## [0.10.2] - 2026-08-08 | 06:49 PM | IST
4
50
 
5
51
  Docs-only patch: no library or CLI behavior changes. Released so the
data/README.md CHANGED
@@ -12,8 +12,8 @@ gem install harnex
12
12
 
13
13
  Harnex itself requires **Ruby 3.x** and uses only the Ruby standard
14
14
  library. Install the CLIs you want to wrap separately; Codex JSON-RPC
15
- support requires Codex CLI **0.128.0 or newer**, and tmux-backed
16
- workflows require `tmux`.
15
+ support requires Codex CLI **0.128.0 or newer**, Pi structured RPC requires
16
+ Pi **0.80.4 or newer**, and tmux-backed workflows require `tmux`.
17
17
 
18
18
  Then ask the CLI what to do next:
19
19
 
@@ -23,8 +23,15 @@ harnex --help
23
23
  harnex agents-guide
24
24
  ```
25
25
 
26
- If you use Codex, run `harnex doctor` after installing or upgrading the
27
- Codex CLI. It verifies the local `codex app-server` prerequisite.
26
+ Run the matching static check after installing or upgrading an agent CLI:
27
+
28
+ ```bash
29
+ harnex doctor --adapter codex
30
+ harnex doctor --adapter pi
31
+ ```
32
+
33
+ These verify the local structured-transport version prerequisites without
34
+ starting a model turn.
28
35
 
29
36
  `harnex agents-guide` is the agent-facing reference for dispatch, chain,
30
37
  buddy, monitoring, and naming patterns. It is packaged in the gem; no skills
@@ -106,7 +113,7 @@ an explicit working-directory/root selector for automation.
106
113
  |-------|---------|
107
114
  | Claude Code | PTY adapter with prompt detection, stop sequence, workspace trust, and vim mode handling |
108
115
  | OpenAI Codex | JSON-RPC `codex app-server` adapter by default; PTY mode remains supported for TUI/interactive use via `--legacy-pty` |
109
- | Pi | JSONL RPC adapter (`pi --mode rpc`) with structured completion, tool events, extension-UI auto-cancel, and session stats telemetry |
116
+ | Pi | JSONL RPC adapter (`pi --mode rpc`, Pi >= 0.80.4) with settled completion, typed failures, tool/retry events, extension-UI auto-cancel, and session stats telemetry |
110
117
  | OpenCode | PTY adapter with native Ctrl+C stop handling and OpenCode-specific prompt/readiness heuristics |
111
118
  | Any terminal CLI | Generic PTY wrapping with local API, logs, status, and best-effort prompt detection |
112
119
 
@@ -121,9 +128,15 @@ terminal UI or PTY-only Codex flags. The flag name is historical; the PTY path
121
128
  is still supported.
122
129
 
123
130
  `harnex run pi` launches `pi --mode rpc` and sends `--context` as a structured
124
- `prompt` command (not a CLI positional argument). Pass Pi child flags after the
125
- separator, for example:
131
+ `prompt` command (not a CLI positional argument). Harnex `--model` / `--effort`
132
+ flags become Pi `--model` / `--thinking` startup controls and are verified over
133
+ RPC before the prompt; use a deterministic `provider/model` value. Pass other
134
+ Pi startup flags after the separator, for example:
126
135
  `harnex run pi --context "Implement X" -- --model anthropic/claude-sonnet-4-5 --thinking high`.
136
+ For unattended runs, explicitly choose Pi project trust with child
137
+ `--approve` or `--no-approve`; Harnex never auto-trusts repository code.
138
+ See [docs/pi-rpc.md](docs/pi-rpc.md) for lifecycle, compatibility, and failure
139
+ semantics.
127
140
 
128
141
  ## Multi-agent workflows
129
142
 
@@ -231,6 +244,8 @@ Choose the wait/watch predicate that matches how you launched the worker:
231
244
  `harnex wait --id ID --until task_complete --timeout SECS` when you need the
232
245
  exact successful-turn event instead of terminal-exit fallback. Use
233
246
  `--until task_failed` to wait specifically for a failed structured turn.
247
+ Pi completion is fenced on `agent_settled`, not the earlier `agent_end`, so
248
+ retries, compaction recovery, and queued continuations finish first.
234
249
  - `harnex send --wait-for-idle` is an atomic send fence for PTY-style
235
250
  interactions. It proves the turn returned to an idle/prompt state, not that
236
251
  your acceptance criteria passed.
data/TECHNICAL.md CHANGED
@@ -363,6 +363,8 @@ Each adapter in `lib/harnex/adapters/` implements:
363
363
  | `inject_exit(writer)` | Send adapter-specific stop text |
364
364
  | `infer_repo_path(argv)` | Extract repo path from CLI args |
365
365
  | `wait_for_sendable(...)` | Wait strategy before sending |
366
+ | `transport` | PTY, JSON-RPC, or JSONL RPC |
367
+ | `validate_runtime!` | Optional version/capability gate |
366
368
 
367
369
  ### Input States
368
370
 
@@ -404,6 +406,29 @@ The adapter reads the screen and returns a state hash:
404
406
  - See `docs/codex-appserver.md` for the full mapping table and
405
407
  troubleshooting.
406
408
 
409
+ ### Pi Adapter (JSONL RPC)
410
+
411
+ - `transport :stdio_jsonl_rpc` launches `pi --mode rpc` and speaks Pi's
412
+ strict LF-delimited JSONL protocol. It is not JSON-RPC 2.0.
413
+ - Requires Pi >= 0.80.4 and fails before spawn when the installed version
414
+ cannot provide the final `agent_settled` fence. Verify with
415
+ `harnex doctor --adapter pi`.
416
+ - `agent_end` remains busy because retry, compaction recovery, or a queued
417
+ continuation may follow. `agent_settled` inspects the authoritative final
418
+ assistant `stopReason`: `stop` completes; `error`, `aborted`, `length`, a
419
+ missing reason, or an unknown reason fails closed.
420
+ - Pi >= 0.84 emits delta-only `message_update` events. Harnex correlates those
421
+ deltas with `message_start` / `message_end` and does not depend on the removed
422
+ cumulative message snapshot.
423
+ - Harnex `--model` and `--effort` become Pi `--model` / `--thinking`
424
+ startup flags and are verified with `get_state`; a model mismatch or clamped
425
+ effort is rejected before prompting without persisting an RPC model switch
426
+ into Pi's user defaults. Forced busy sends use `streamingBehavior=steer`.
427
+ - RPC requests have a 30-second response bound. Stderr is continuously drained
428
+ into a bounded diagnostic tail, and subprocess status comes from Open3's wait
429
+ thread instead of racing it with another `waitpid`.
430
+ - See `docs/pi-rpc.md` for trust policy, telemetry, and compatibility details.
431
+
407
432
  #### Codex Adapter (legacy PTY — `--legacy-pty`, long-term fallback)
408
433
 
409
434
  - Launches with `--no-alt-screen` for inline screen output
data/docs/pi-rpc.md ADDED
@@ -0,0 +1,151 @@
1
+ # Pi RPC adapter
2
+
3
+ Harnex runs Pi workers over Pi's structured subprocess protocol rather than
4
+ scraping the TUI:
5
+
6
+ ```text
7
+ harnex run pi -> pi --mode rpc -> LF-delimited JSON objects on stdio
8
+ ```
9
+
10
+ Pi RPC is JSONL, not JSON-RPC 2.0. Codex uses `codex app-server` JSON-RPC;
11
+ Harnex presents the same run/send/wait/events control surface over both.
12
+
13
+ ## Compatibility
14
+
15
+ Harnex requires Pi **0.80.4 or newer** for structured dispatch. Pi 0.80.4 added
16
+ `agent_settled`, the first lifecycle event that guarantees no automatic retry,
17
+ compaction recovery, or queued continuation remains. `agent_end` is only a
18
+ low-level run boundary and is never accepted as Harnex work completion.
19
+
20
+ Check the installed transport before unattended work:
21
+
22
+ ```bash
23
+ harnex doctor --adapter pi
24
+ ```
25
+
26
+ `harnex run pi` also checks the version before spawn and fails closed when the
27
+ version cannot be parsed or predates 0.80.4. The current contract was live
28
+ verified against Pi 0.84.1.
29
+
30
+ Pi 0.84.0 removed cumulative message snapshots from `message_update`. Harnex
31
+ therefore assembles output from `assistantMessageEvent` deltas between
32
+ `message_start` and authoritative `message_end` events. This remains compatible
33
+ with Pi 0.80.4-0.83.x and avoids printing the final message twice on 0.84+.
34
+
35
+ ## Dispatch
36
+
37
+ ```bash
38
+ harnex run pi --id cx-i-42 --tmux cx-i-42 \
39
+ --context "Implement issue 42 and run tests" --auto-stop
40
+
41
+ harnex wait --id cx-i-42 --until task_complete --timeout 3600
42
+ harnex events --id cx-i-42
43
+ ```
44
+
45
+ `--context` is sent as an RPC `prompt` command; it is not passed as a Pi
46
+ positional argument.
47
+
48
+ Harnex model and effort flags are active controls, not telemetry-only labels:
49
+
50
+ ```bash
51
+ harnex run pi \
52
+ --model anthropic/claude-sonnet-4-5 --effort high \
53
+ --context "Review the change" --auto-stop
54
+ ```
55
+
56
+ Harnex maps those controls to Pi `--model` / `--thinking` startup flags, then
57
+ verifies the effective values with RPC `get_state` before prompting. This avoids
58
+ `set_model` / `set_thinking_level` persisting a dispatch override into the
59
+ user's Pi defaults. Use `provider/model` for deterministic dispatch. Harnex
60
+ fails before the prompt if Pi cannot apply the requested model or thinking level
61
+ exactly, so requested and effective policy cannot silently diverge. Direct
62
+ adapter callers can still use Pi's RPC setters for an intentional mid-session
63
+ switch.
64
+
65
+ Pass Pi startup flags after `--`:
66
+
67
+ ```bash
68
+ harnex run pi --context "Implement X" --auto-stop -- \
69
+ --no-session --no-extensions --model anthropic/claude-sonnet-4-5
70
+ ```
71
+
72
+ When both Harnex `--model` and a Pi child `--model` are supplied, the Harnex
73
+ startup override is appended last and is authoritative for that dispatch.
74
+
75
+ ## Project trust
76
+
77
+ Pi 0.79 added project trust. RPC mode cannot show the interactive trust prompt;
78
+ an unresolved project follows Pi's global `defaultProjectTrust` policy. Harnex
79
+ does not auto-trust repository code.
80
+
81
+ For deterministic unattended runs, choose explicitly after reviewing the
82
+ repository:
83
+
84
+ ```bash
85
+ # Load trusted project-local .pi settings, packages, and extensions
86
+ harnex run pi --context "..." -- --approve
87
+
88
+ # Ignore project-local executable resources
89
+ harnex run pi --context "..." -- --no-approve
90
+ ```
91
+
92
+ Context files continue to follow Pi's documented trust behavior. Third-party or
93
+ project extensions execute with the worker's OS privileges; `--approve` is a
94
+ security decision, not merely a prompt-suppression flag.
95
+
96
+ ## Lifecycle and failures
97
+
98
+ Harnex maps Pi events as follows:
99
+
100
+ | Pi event | Harnex behavior |
101
+ |---|---|
102
+ | `agent_start`, `turn_start` | busy |
103
+ | `agent_end` | remain busy; capture outcome/stats, but do not complete |
104
+ | retry/compaction events | remain busy and emit structured telemetry |
105
+ | `agent_settled` + final `stopReason=stop` | emit `task_complete` |
106
+ | `agent_settled` + `error`, `aborted`, or `length` | emit `task_failed` |
107
+ | `agent_settled` without an authoritative final stop reason | fail closed |
108
+ | EOF, malformed JSON, or request timeout | disconnect/failure |
109
+
110
+ A forced send while Pi is busy is translated to a `prompt` with
111
+ `streamingBehavior="steer"`; ordinary sends wait for the settled prompt state.
112
+ This avoids sending a plain prompt that current Pi correctly rejects while
113
+ streaming.
114
+
115
+ RPC requests are bounded, stderr is continuously drained into a bounded
116
+ diagnostic tail, and Harnex consumes Open3's wait thread so the real subprocess
117
+ exit status is not lost to a competing `waitpid`.
118
+
119
+ ## Telemetry
120
+
121
+ At settlement and teardown, Harnex reads `get_session_stats` and records:
122
+
123
+ - input/output/cache/total tokens and provider-reported cost;
124
+ - tool-call count;
125
+ - Pi session ID;
126
+ - effective provider and model;
127
+ - terminal and peak active-context usage.
128
+
129
+ Message/tool/retry/compaction events are available through `harnex events`.
130
+ Blocking extension dialogs (`select`, `confirm`, `input`, `editor`) are
131
+ currently auto-cancelled so autonomous workers cannot hang. Full extension UI
132
+ mediation is not implemented.
133
+
134
+ ## Boundaries
135
+
136
+ - The structured adapter does not embed Pi's TypeScript SDK.
137
+ - `--tmux` displays Harnex's synthesized RPC transcript, not Pi's native TUI.
138
+ - First-class native Pi TUI/PTTY markers remain tracked in Issue `#45`.
139
+ - Durable Pi session recovery is separate from lifecycle correctness and remains
140
+ part of the broader structured-recovery work.
141
+
142
+ ## Verification
143
+
144
+ Hermetic tests cover lifecycle settlement, retry boundaries, failure stop
145
+ reasons, delta-only message streaming, model/effort RPC controls, request
146
+ timeouts, and telemetry. The opt-in live contract test is:
147
+
148
+ ```bash
149
+ PI_INTEGRATION=1 ruby -Ilib -Itest \
150
+ test/harnex/adapters/pi_integration_test.rb
151
+ ```
@@ -148,8 +148,13 @@ to the canonical repo/global dispatch stream, which is the only telemetry
148
148
  destination. Repo `.harnex/config.json` can warn on or reject non-canonical
149
149
  phase names before spawn.
150
150
 
151
- Pi runs use structured RPC (`pi --mode rpc`). Pass Pi child flags after `--`
152
- (e.g. `harnex run pi --context "..." -- --model anthropic/claude-sonnet-4-5 --thinking high`).
151
+ Pi runs use structured RPC (`pi --mode rpc`) and require Pi >= 0.80.4; gate
152
+ unattended work with `harnex doctor --adapter pi`. Harnex `--model` / `--effort`
153
+ apply verified Pi startup controls before the prompt. Pass other Pi startup
154
+ flags after `--` (e.g. `harnex run pi --context "..." -- --model
155
+ anthropic/claude-sonnet-4-5 --thinking high`).
156
+ Since RPC cannot display Pi's trust prompt, explicitly pass child `--approve`
157
+ for reviewed project-local resources or `--no-approve` to ignore them.
153
158
 
154
159
  Codex flag forms differ between transports. The default JSON-RPC adapter
155
160
  (`codex app-server`) does not accept `-m`/`--model`; pass the model as
@@ -68,8 +68,11 @@ named parent is still running in the same repo is refused. Wait for the parent
68
68
  (`harnex wait --id <parent> --until done`) or stop it first. Pass
69
69
  `--allow-live-parent` only for intentional parallelism (e.g. isolated
70
70
  worktrees). `--attempt-kind review` is exempt: a completed parent may still
71
- sit at a live prompt while its work is reviewed. For structured sessions (Pi RPC and Codex app-server),
72
- `harnex wait --until task_complete` remains the exact accepted-turn fence.
71
+ sit at a live prompt while its work is reviewed. For structured sessions (Pi
72
+ RPC and Codex app-server), `harnex wait --until task_complete` remains the exact
73
+ accepted-turn fence. Pi reaches that fence only at `agent_settled`; an earlier
74
+ `agent_end` can still be followed by retry, compaction recovery, or queued work.
75
+ Final Pi `error`, `aborted`, and `length` stop reasons emit `task_failed`.
73
76
  Codex acknowledgment-only auto-stop turns are typed
74
77
  `completed_no_activity` and fail this fence without transcript parsing. Harnex
75
78
  writes the observed-state receipt before publishing `task_complete`; optional
@@ -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, $?]
@@ -145,6 +145,9 @@ module Harnex
145
145
  Passing --tmux without --id creates a random harnex session ID.
146
146
  --watch is foreground-only; do not combine it with --tmux or --detach.
147
147
  Use -- before child CLI flags when a flag could be parsed by harnex.
148
+ Pi RPC requires Pi >= 0.80.4 (`harnex doctor --adapter pi`). Prefer
149
+ provider/model for Harnex --model, and explicitly pass child
150
+ --approve or --no-approve for deterministic project trust.
148
151
  Codex JSON-RPC: pass model as `-c model=NAME`, not `-m NAME`. The
149
152
  legacy PTY adapter (--legacy-pty) accepts `-m`.
150
153
  TEXT
@@ -230,7 +233,7 @@ module Harnex
230
233
  end
231
234
 
232
235
  def run_detached(adapter, cli_name, child_args, repo_root)
233
- Session.validate_binary!(adapter.build_command)
236
+ validate_adapter!(adapter)
234
237
 
235
238
  if @options[:tmux]
236
239
  run_in_tmux(cli_name, child_args, repo_root)
@@ -241,7 +244,7 @@ module Harnex
241
244
  end
242
245
 
243
246
  def run_watch_mode(adapter, repo_root)
244
- Session.validate_binary!(adapter.build_command)
247
+ validate_adapter!(adapter)
245
248
 
246
249
  result = run_headless(adapter, repo_root, emit_payload: false)
247
250
  return result[:exit_code] unless result[:ok]
@@ -424,7 +427,21 @@ module Harnex
424
427
  raise OptionParser::InvalidOption, "harnex run: invalid config #{e.message}"
425
428
  end
426
429
 
430
+ def validate_adapter!(adapter)
431
+ configure_adapter_startup!(adapter)
432
+ Session.validate_binary!(adapter.build_command)
433
+ adapter.validate_runtime! if adapter.respond_to?(:validate_runtime!)
434
+ end
435
+
436
+ def configure_adapter_startup!(adapter)
437
+ return unless adapter.respond_to?(:configure_startup)
438
+
439
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
440
+ adapter.configure_startup(model: metadata["model"], effort: metadata["effort"])
441
+ end
442
+
427
443
  def build_session(adapter, repo_root)
444
+ configure_adapter_startup!(adapter)
428
445
  watch = Harnex.build_watch_config(@options[:watch], repo_root)
429
446
  Session.new(
430
447
  adapter: adapter,
@@ -161,6 +161,10 @@ module Harnex
161
161
  @completion_report_status = nil
162
162
  @completion_diagnostics = []
163
163
  @pi_streamed_text_by_message = {}
164
+ @pi_active_message_key = nil
165
+ @pi_last_assistant_stop_reason = nil
166
+ @pi_last_assistant_error = nil
167
+ @pi_last_retry_error = nil
164
168
  @auto_stop = !!auto_stop
165
169
  @auto_stop_fired = false
166
170
  @auto_stop_seen_busy = false
@@ -197,6 +201,7 @@ module Harnex
197
201
  end
198
202
 
199
203
  def run(validate_binary: true)
204
+ configure_adapter_startup!
200
205
  validate_binary! if validate_binary
201
206
  prune_retained_logs
202
207
  prepare_output_log
@@ -471,10 +476,18 @@ module Harnex
471
476
 
472
477
  def validate_binary!
473
478
  self.class.validate_binary!(command)
479
+ adapter.validate_runtime! if adapter.respond_to?(:validate_runtime!)
474
480
  end
475
481
 
476
482
  private
477
483
 
484
+ def configure_adapter_startup!
485
+ return unless adapter.respond_to?(:configure_startup)
486
+
487
+ adapter.configure_startup(model: meta_hash["model"], effort: meta_hash["effort"])
488
+ @command = adapter.build_command
489
+ end
490
+
478
491
  def structured_transport?
479
492
  %i[stdio_jsonrpc stdio_jsonl_rpc].include?(adapter.transport)
480
493
  end
@@ -501,9 +514,18 @@ module Harnex
501
514
 
502
515
  if @pid
503
516
  begin
504
- _, status = Process.wait2(@pid)
505
- @term_signal = status.signaled? ? status.termsig : nil
506
- @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
517
+ status = if adapter.respond_to?(:wait_for_exit)
518
+ adapter.wait_for_exit
519
+ else
520
+ _waited_pid, process_status = Process.wait2(@pid)
521
+ process_status
522
+ end
523
+ if status
524
+ @term_signal = status.signaled? ? status.termsig : nil
525
+ @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
526
+ else
527
+ @exit_code = 0
528
+ end
507
529
  rescue Errno::ECHILD
508
530
  @exit_code = 0
509
531
  end
@@ -792,29 +814,43 @@ module Harnex
792
814
  event_type = message["type"].to_s
793
815
 
794
816
  case event_type
795
- when "agent_start", "turn_start"
796
- @turn_started_seen = true if event_type == "turn_start"
817
+ when "agent_start"
818
+ reset_pi_run_outcome!
797
819
  @state_machine.force_busy!
798
- emit_event("turn_started") if event_type == "turn_start"
820
+ when "turn_start"
821
+ @turn_started_seen = true
822
+ @state_machine.force_busy!
823
+ emit_event("turn_started")
799
824
  when "agent_end"
825
+ capture_pi_agent_end(message)
826
+ @state_machine.force_busy!
827
+ emit_event("agent_end", will_retry: message["willRetry"] == true)
828
+ adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
829
+ when "agent_settled"
800
830
  @state_machine.force_prompt!
801
- record_successful_completion({})
831
+ emit_event("agent_settled", stop_reason: @pi_last_assistant_stop_reason)
832
+ settle_pi_task!
802
833
  adapter.request_session_stats_async if adapter.respond_to?(:request_session_stats_async)
803
- schedule_auto_stop("task_complete", interrupt: false)
834
+ schedule_auto_stop("agent_settled", interrupt: false) if task_complete? || task_failed?
804
835
  when "message_start"
805
- @pi_streamed_text_by_message[pi_message_key(message["message"])] = false
836
+ @pi_active_message_key = pi_message_key(message["message"])
837
+ @pi_streamed_text_by_message[@pi_active_message_key] = false
806
838
  when "message_update"
807
839
  event = message["assistantMessageEvent"] || {}
808
840
  delta = event["delta"]
809
- key = pi_message_key(message["message"])
841
+ key = @pi_active_message_key || pi_message_key(message["message"])
810
842
  if event["type"] == "text_delta" && delta && !delta.empty?
811
843
  @pi_streamed_text_by_message[key] = true
812
844
  record_synthesized(delta, newline: false)
813
845
  end
814
846
  when "message_end"
815
- key = pi_message_key(message["message"])
847
+ key = @pi_active_message_key || pi_message_key(message["message"])
816
848
  streamed = @pi_streamed_text_by_message.delete(key)
817
- unless streamed
849
+ @pi_active_message_key = nil
850
+ capture_pi_assistant_message(message["message"])
851
+ if streamed
852
+ record_synthesized("\n", newline: false)
853
+ else
818
854
  text = pi_extract_message_text(message["message"])
819
855
  record_synthesized(text) if text
820
856
  end
@@ -828,13 +864,25 @@ module Harnex
828
864
  status = message["isError"] ? "error" : "ok"
829
865
  record_synthesized("tool-result: #{tool_name} (#{status})")
830
866
  when "compaction_start", "compaction_end"
831
- emit_event("compaction", reason: message["reason"], phase: event_type)
867
+ emit_event(
868
+ "compaction",
869
+ reason: message["reason"],
870
+ phase: event_type,
871
+ will_retry: message["willRetry"],
872
+ aborted: message["aborted"],
873
+ error_message: message["errorMessage"]
874
+ )
832
875
  when "queue_update"
833
876
  nil
834
877
  when "auto_retry_start", "auto_retry_end"
835
878
  payload = message.reject { |k, _| k == "type" }
879
+ @pi_last_retry_error = message["finalError"].to_s if event_type == "auto_retry_end" && message["success"] == false
836
880
  emit_event(event_type, **payload)
837
881
  record_attempt_transition(type: "attempt_retry_scheduled", trigger: "adapter_auto_retry") if event_type == "auto_retry_start"
882
+ when "summarization_retry_scheduled", "summarization_retry_attempt_start", "summarization_retry_finished"
883
+ emit_event(event_type, **message.reject { |k, _| k == "type" })
884
+ when "bash_execution_update"
885
+ emit_event(event_type, id: message["id"], bytes: message["delta"].to_s.bytesize)
838
886
  when "extension_ui_request"
839
887
  handle_extension_ui_request(message)
840
888
  when "extension_error"
@@ -876,9 +924,19 @@ module Harnex
876
924
  return
877
925
  end
878
926
 
927
+ if msg.to_s.empty? && adapter.respond_to?(:disconnect_diagnostic)
928
+ msg = adapter.disconnect_diagnostic
929
+ end
879
930
  @last_error = msg.to_s unless msg.to_s.empty?
880
931
  @state_machine.force_busy!
881
932
  emit_event("disconnected", source: "transport", message: msg) rescue nil
933
+ if adapter.respond_to?(:terminate_subprocess)
934
+ Thread.new do
935
+ adapter.terminate_subprocess
936
+ rescue StandardError
937
+ nil
938
+ end
939
+ end
882
940
  signal_rpc_done!
883
941
  end
884
942
 
@@ -946,6 +1004,52 @@ module Harnex
946
1004
  ""
947
1005
  end
948
1006
 
1007
+ def reset_pi_run_outcome!
1008
+ @pi_last_assistant_stop_reason = nil
1009
+ @pi_last_assistant_error = nil
1010
+ @pi_last_retry_error = nil
1011
+ end
1012
+
1013
+ def capture_pi_agent_end(message)
1014
+ assistant = Array(message["messages"]).reverse.find do |candidate|
1015
+ candidate.is_a?(Hash) && candidate["role"] == "assistant"
1016
+ end
1017
+ capture_pi_assistant_message(assistant) if assistant
1018
+ end
1019
+
1020
+ def capture_pi_assistant_message(message)
1021
+ return unless message.is_a?(Hash) && message["role"] == "assistant"
1022
+
1023
+ reason = message["stopReason"].to_s
1024
+ @pi_last_assistant_stop_reason = reason unless reason.empty?
1025
+ error = message["errorMessage"].to_s
1026
+ @pi_last_assistant_error = error unless error.empty?
1027
+ end
1028
+
1029
+ def settle_pi_task!
1030
+ return if @stop_requested && !task_complete? && !task_failed?
1031
+
1032
+ reason = @pi_last_assistant_stop_reason
1033
+ case reason
1034
+ when "stop"
1035
+ record_successful_completion({})
1036
+ when "error", "aborted", "length"
1037
+ error = @pi_last_assistant_error || @pi_last_retry_error
1038
+ error = "Pi agent settled with stopReason=#{reason}" if error.to_s.empty?
1039
+ mark_task_failed(status: reason, error: error)
1040
+ when nil, ""
1041
+ mark_task_failed(
1042
+ status: "missing_final_message",
1043
+ error: "Pi agent settled without an authoritative final assistant stop reason"
1044
+ )
1045
+ else
1046
+ mark_task_failed(
1047
+ status: "unsupported_stop_reason",
1048
+ error: "Pi agent settled with unsupported stopReason=#{reason}"
1049
+ )
1050
+ end
1051
+ end
1052
+
949
1053
  def pi_extract_message_text(message)
950
1054
  return nil unless message.is_a?(Hash)
951
1055
 
@@ -2193,8 +2297,9 @@ module Harnex
2193
2297
  end
2194
2298
 
2195
2299
  def summary_model
2196
- meta_hash["model"] || @usage_summary[:model] ||
2197
- (adapter.current_model if adapter.respond_to?(:current_model))
2300
+ @usage_summary[:model] ||
2301
+ (adapter.current_model if adapter.respond_to?(:current_model)) ||
2302
+ meta_hash["model"]
2198
2303
  end
2199
2304
 
2200
2305
  def summary_service_tier
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.10.2"
3
- RELEASE_DATE = "2026-08-08"
2
+ VERSION = "0.11.0"
3
+ RELEASE_DATE = "2026-08-14"
4
4
  end
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.10.2
4
+ version: 0.11.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-08-08 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex, Pi)
14
14
  and adds a control plane for discovery, messaging, and coordination.
@@ -30,6 +30,7 @@ files:
30
30
  - docs/configuration.md
31
31
  - docs/dispatch-telemetry.md
32
32
  - docs/events.md
33
+ - docs/pi-rpc.md
33
34
  - guides/01_dispatch.md
34
35
  - guides/02_chain.md
35
36
  - guides/03_buddy.md