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 +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +22 -7
- data/TECHNICAL.md +25 -0
- data/docs/pi-rpc.md +151 -0
- data/guides/01_dispatch.md +7 -2
- data/guides/04_monitoring.md +5 -2
- data/lib/harnex/adapters/pi.rb +220 -21
- data/lib/harnex/commands/doctor.rb +57 -9
- data/lib/harnex/commands/run.rb +19 -2
- data/lib/harnex/runtime/session.rb +120 -15
- data/lib/harnex/version.rb +2 -2
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bcf0c88485eceb630094f32cd520c12f6c3d5915f2e2b7790191c2ed98a236a5
|
|
4
|
+
data.tar.gz: f228c15c98a47d69b5b4c7129373f88d5eb42f6413ac998995294ef3271d99e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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**,
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
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).
|
|
125
|
-
|
|
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
|
+
```
|
data/guides/01_dispatch.md
CHANGED
|
@@ -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`)
|
|
152
|
-
|
|
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
|
data/guides/04_monitoring.md
CHANGED
|
@@ -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
|
|
72
|
-
`harnex wait --until task_complete` remains the exact
|
|
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
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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"
|
|
796
|
-
|
|
817
|
+
when "agent_start"
|
|
818
|
+
reset_pi_run_outcome!
|
|
797
819
|
@state_machine.force_busy!
|
|
798
|
-
|
|
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
|
-
|
|
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("
|
|
834
|
+
schedule_auto_stop("agent_settled", interrupt: false) if task_complete? || task_failed?
|
|
804
835
|
when "message_start"
|
|
805
|
-
@
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/harnex/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harnex
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.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-
|
|
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
|