harnex 0.7.7 → 0.7.9

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: '09276571f581a98eb6c613e88e4035ed8420bcae710c43d57e307cc5eb4d468b'
4
- data.tar.gz: 886e1555b2ee4d128d992e4d35aafc716c0bc2aa16318123b053e5e128a7337a
3
+ metadata.gz: 56d9111b2ab4f12716444ecef721331d9878a4485a4b305ecd4a1ad7546d4b77
4
+ data.tar.gz: 3f727b473455245ba916cabe1202a6abc2d6c5a07f4ae257b04898a296d9149c
5
5
  SHA512:
6
- metadata.gz: 90f7f457f829f99424c1f2635706c3231c08bf1cea7329e47b6992fde3aa45e123c40c0d278bf2c9a5c8bc2ecd6d0f742a812320c659c5bbe68fd4ab94df6c9d
7
- data.tar.gz: 65323fa96b6f5c92564b2be10cfe802f846fff1b76079f26cbaa362588dce50ae86a59c250b456058f3acad8ca31f03c9c49b2ad096c85a6aae5510797ff3abb
6
+ metadata.gz: a2a87ae57e9ddc34bb85805ea9e07f3523691cf979361f687f4c3c68534c0b9f7c9e276b83d8d28a91a15bc52913ef41e1e36b1993127c97c68c87aa75af5766
7
+ data.tar.gz: 92084ab180df22e16316226adaaa8170b0ab71ceeda1e4de3f097873ed6ecb8e22f8bca7b06b3c78b0a1a0c36d8b18c4b13dcebb7126aecd4523034fead38399
data/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.9] - 2026-07-10 | 10:33 PM | IST
6
+
7
+ ### Added
8
+
9
+ - `harnex run` now accepts wrapper-level `--cwd DIR` and `--root DIR` options.
10
+ `--cwd` starts the wrapped agent in `DIR` and makes that directory the
11
+ harnex session root for registry/session metadata and default dispatch
12
+ summaries. `--root` changes harnex root attribution without changing the
13
+ child process cwd.
14
+
15
+ ### Changed
16
+
17
+ - `harnex run --help`, README, and the dispatch guide now document
18
+ public-bundle / temporary-workdir dispatches with `--cwd`.
19
+ - Refreshed pinned Codex app-server schema fixtures against
20
+ `codex-cli 0.144.1` so the release suite is green with the current local
21
+ Codex CLI.
22
+
23
+ ### Fixed
24
+
25
+ - Non-git temporary work directories no longer leak raw `git rev-parse` fatal
26
+ messages during harnex root probing.
27
+
28
+ ## [0.7.8] - 2026-06-13 | 08:45 PM | IST
29
+
30
+ ### Added
31
+
32
+ - `harnex watch --id <id> --until done` now provides a native work-terminal
33
+ watcher for existing visible/detached sessions. It exits `0` for successful
34
+ work, non-zero for `task_failed` / failed terminal telemetry, `124` for
35
+ wall-clock timeout, and can write optional done/fail marker files for legacy
36
+ queue integrations.
37
+
38
+ ### Changed
39
+
40
+ - Monitoring docs now recommend native `harnex watch` for unattended
41
+ single-dispatch monitoring and reserve `harnex run --watch` for foreground
42
+ launch-and-stall-recovery.
43
+
5
44
  ## [0.7.7] - 2026-06-12 | 10:48 AM | IST
6
45
 
7
46
  ### Fixed
data/README.md CHANGED
@@ -57,6 +57,24 @@ harnex stop --id planner
57
57
  That's the core loop. Start a fresh agent for each step, hand it one
58
58
  job, watch it work, stop it when done.
59
59
 
60
+ ### Run from a temporary/public bundle
61
+
62
+ Use `--cwd DIR` when the worker should see a specific directory rather than
63
+ the orchestrator's current repo. Harnex starts the wrapped agent in `DIR`, sets
64
+ that directory as the session root, and resolves default telemetry such as
65
+ `.harnex/dispatch.jsonl` there:
66
+
67
+ ```bash
68
+ harnex run codex --cwd /tmp/leximaze_eval_run_001 \
69
+ --id lm-run-001 \
70
+ --context "Read README.md and write RESPONSES.jsonl and OUTPUT.md" \
71
+ --auto-stop
72
+ ```
73
+
74
+ Use `--root DIR` only when you need to override harnex's root attribution
75
+ without changing the child process cwd. `--cwd` is not a security sandbox; it is
76
+ an explicit working-directory/root selector for automation.
77
+
60
78
  ## Why use this
61
79
 
62
80
  - **You want agents to plan, implement, review, and fix — in sequence.**
@@ -68,8 +86,9 @@ job, watch it work, stop it when done.
68
86
  `harnex events` streams structured JSONL lifecycle events.
69
87
 
70
88
  - **You don't want to babysit.** Use `--context --auto-stop` for
71
- one-shot work, `--watch` for bounded stall recovery, or
72
- `--wait-for-idle` as a send fence.
89
+ one-shot work, `harnex watch` for existing visible/detached dispatches,
90
+ `run --watch` for bounded foreground stall recovery, or `--wait-for-idle`
91
+ as a send fence.
73
92
 
74
93
  - **You want local-only orchestration.** Everything runs on your
75
94
  machine. No cloud services, no API keys beyond what the agents need.
@@ -150,18 +169,27 @@ harnex agents-guide monitoring
150
169
 
151
170
  ## Built-in dispatch monitoring
152
171
 
153
- For unattended dispatches, use `--watch` instead of writing a bash poll loop:
172
+ For unattended visible/background dispatches, use `harnex watch` instead of
173
+ writing a bash poll loop around `harnex wait`:
154
174
 
155
175
  ```bash
156
- harnex run pi --id pi-impl-42 --watch --preset impl \
176
+ harnex run pi --id pi-impl-42 --tmux pi-impl-42 \
157
177
  --context "Implement koder/plans/42_plan.md. Run tests and commit when done."
178
+ harnex watch --id pi-impl-42 --until done --max-wait 90m \
179
+ --done-marker /tmp/pi-impl-42-done.json \
180
+ --fail-marker /tmp/pi-impl-42-failed.json
158
181
  ```
159
182
 
160
- `--watch` runs a foreground babysitter that checks session activity every 60s,
161
- force-resumes on stall up to a cap, and exits when the target session exits or
162
- the resume cap is reached. It is foreground-only; use `--tmux` or `--detach`
163
- for visible/background sessions, and `--watch` when the current command should
164
- block as the monitor.
183
+ `harnex watch --until done` is the safe work-terminal watcher for existing
184
+ `--tmux` or detached sessions. It exits `0` for `task_complete`/done, non-zero
185
+ for `task_failed` or failed terminal summaries, and `124` for `--max-wait`
186
+ timeouts. It does not keep pane/status polling after a terminal failure signal.
187
+
188
+ `harnex run --watch` is a separate foreground babysitter that checks session
189
+ activity every 60s, force-resumes on stall up to a cap, and exits when the
190
+ target session exits or the resume cap is reached. It is foreground-only; use
191
+ `--tmux` or `--detach` for visible/background sessions, and `run --watch` when
192
+ the current command should launch and monitor one worker.
165
193
 
166
194
  Presets map to stall policy defaults:
167
195
 
@@ -180,11 +208,15 @@ and stops the session after the first task completion or PTY prompt return.
180
208
 
181
209
  ## Completion and waiting
182
210
 
183
- Choose the wait predicate that matches how you launched the worker:
211
+ Choose the wait/watch predicate that matches how you launched the worker:
184
212
 
185
- - `harnex wait --id ID --until done --timeout SECS` is the safest unattended
186
- work fence. It returns when Harnex sees `task_complete`, `task_failed`, or a
187
- terminal exit, whichever comes first; failed work returns non-zero.
213
+ - `harnex watch --id ID --until done --max-wait DUR` is the safest unattended
214
+ monitor for an existing visible or detached dispatch. It wraps the work-level
215
+ fence, preserves the timeout/failure distinction, and can write done/fail
216
+ marker files for legacy queue integrations.
217
+ - `harnex wait --id ID --until done --timeout SECS` is the primitive work fence.
218
+ It returns when Harnex sees `task_complete`, `task_failed`, or a terminal
219
+ exit, whichever comes first; failed work returns non-zero.
188
220
  - `harnex wait --id ID` waits for the wrapped process to exit. This is right
189
221
  for already-exited sessions and terminal-summary recovery, but interactive
190
222
  agents can stay open after finishing a turn.
@@ -214,9 +246,11 @@ Schema details and compatibility policy are documented in
214
246
 
215
247
  ## Dispatch history
216
248
 
217
- Every finished `harnex run` writes dispatch records. In a git repo, the
218
- default path is `<repo>/.harnex/dispatch.jsonl`; outside a git repo, the
219
- compact history record falls back to `~/.local/state/harnex/dispatch.jsonl`.
249
+ Every finished `harnex run` writes dispatch records. By default, the terminal
250
+ summary JSONL path is `<session-root>/.harnex/dispatch.jsonl`; `--cwd DIR` makes
251
+ `DIR` the session root, including for non-git temporary bundles. The compact
252
+ history record is repo-local in a git tree and falls back to
253
+ `~/.local/state/harnex/dispatch.jsonl` outside git.
220
254
  `harnex history` reads the compact records from that location, and
221
255
  `harnex status --id ID --json` / `harnex wait` can use the same durable
222
256
  terminal summaries when the live session registry is already gone.
@@ -313,6 +347,7 @@ See [recipes/03_buddy.md](recipes/03_buddy.md) for the full pattern.
313
347
  | `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until the turn returns idle) |
314
348
  | `harnex stop --id <id>` | Send the agent's native exit sequence |
315
349
  | `harnex status` | List running sessions; with `--id ID --json`, terminal summaries can classify completed/failed sessions after exit |
350
+ | `harnex watch --id <id>` | Safely monitor existing visible/detached work until `done`, `task_failed`, or timeout; optional done/fail markers |
316
351
  | `harnex pane --id <id>` | Capture a tmux-backed session's screen (`--follow` for live) |
317
352
  | `harnex logs --id <id>` | Read session transcript (`--follow` to tail) |
318
353
  | `harnex events --id <id>` | Stream structured session events (`--snapshot` for non-blocking dump) |
@@ -73,7 +73,7 @@ for i in 1 2 3; do
73
73
  harnex run pi --id w-$i --tmux w-$i --detach \
74
74
  --context "Read and execute /tmp/task-$i.md" --auto-stop &
75
75
  done
76
- for i in 1 2 3; do harnex wait --id w-$i --until done & done
76
+ for i in 1 2 3; do harnex watch --id w-$i --until done --max-wait 90m & done
77
77
  wait
78
78
  ```
79
79
 
@@ -81,6 +81,20 @@ Rule: when you use `--tmux`, pass the same name as `--id`. If you pass only
81
81
  `--tmux NAME`, harnex creates a random session ID and the pane name no longer
82
82
  matches `harnex status` or `harnex pane --id`.
83
83
 
84
+ For public-bundle or benchmark runs, use `--cwd DIR` to make harnex launch the
85
+ wrapped agent from that directory and associate session metadata/default
86
+ telemetry with it:
87
+
88
+ ```bash
89
+ harnex run codex --cwd /tmp/leximaze_eval_run_001 \
90
+ --id lm-run-001 \
91
+ --context "Read README.md and write RESPONSES.jsonl and OUTPUT.md" \
92
+ --auto-stop
93
+ ```
94
+
95
+ `--root DIR` only overrides harnex's root attribution; it does not change the
96
+ child process cwd. Neither flag is a sandbox.
97
+
84
98
  Pi runs use structured RPC (`pi --mode rpc`). Pass Pi child flags after `--`
85
99
  (e.g. `harnex run pi --context "..." -- --model anthropic/claude-sonnet-4-5 --thinking high`).
86
100
 
@@ -125,18 +139,24 @@ Use the lightest primitive that gives the signal you need:
125
139
  | Continuous pane view | `harnex pane --id pi-i-NN --follow` |
126
140
  | Transcript tail | `harnex logs --id pi-i-NN --lines 80` |
127
141
  | Structured events | `harnex events --id pi-i-NN --snapshot` |
128
- | Work completion/failure fence | `harnex wait --id pi-i-NN --until done` |
142
+ | Existing-session work monitor | `harnex watch --id pi-i-NN --until done --max-wait 90m` |
143
+ | Primitive work completion/failure fence | `harnex wait --id pi-i-NN --until done` |
129
144
  | Native successful-turn completion | `harnex wait --id pi-i-NN --until task_complete` |
130
145
 
131
- For unattended policy-only stall recovery, use built-in watch mode:
146
+ For visible `--tmux` or detached dispatches, prefer `harnex watch --id`: it
147
+ returns `0` on done, non-zero on `task_failed`/failed terminal summaries, and
148
+ `124` on `--max-wait` timeout. Use `--done-marker` / `--fail-marker` only as
149
+ compatibility outputs for older queue scripts.
150
+
151
+ For foreground launch-and-stall-recovery, use `harnex run --watch`:
132
152
 
133
153
  ```bash
134
154
  harnex run pi --id pi-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
135
155
  ```
136
156
 
137
- `--watch` is foreground-blocking. Use it when a single process should launch
138
- and monitor the worker. Use pane/log/event polling or a buddy when you need
139
- interpretation, multiple sessions, or a separate watcher.
157
+ `run --watch` is foreground-blocking. Use it when a single process should
158
+ launch and monitor the worker. Use pane/log/event polling or a buddy when you
159
+ need interpretation across multiple sessions.
140
160
 
141
161
  ## Verify And Stop
142
162
 
data/guides/03_buddy.md CHANGED
@@ -4,7 +4,14 @@ A buddy is a second harnex session that watches one or more workers and nudges
4
4
  them if they stall. Use a buddy when the work is long-running, unattended, or
5
5
  needs interpretation that simple stall policy cannot provide.
6
6
 
7
- For simple inactivity recovery, prefer built-in watch mode:
7
+ For simple work-terminal monitoring of an existing visible/detached session,
8
+ prefer the native watcher:
9
+
10
+ ```bash
11
+ harnex watch --id pi-i-NN --until done --max-wait 90m
12
+ ```
13
+
14
+ For foreground launch-and-inactivity recovery, use built-in run watch mode:
8
15
 
9
16
  ```bash
10
17
  harnex run pi --id pi-i-NN --watch --preset impl --context "Read /tmp/task-impl-NN.md"
@@ -17,12 +17,15 @@ Prefer signals in this order:
17
17
  | `harnex pane` | Live UI interpretation and prompt/error diagnosis |
18
18
  | `harnex status` | Session liveness and coarse state |
19
19
 
20
- For unattended monitors, prefer `harnex wait --until done`: it returns on the
21
- work-level `task_complete` or `task_failed` signal, or terminal exit, whichever
22
- comes first. Failed work returns non-zero. For structured sessions (Pi RPC and
23
- Codex app-server), `harnex wait --until task_complete` remains the exact
24
- successful-turn fence. Neither knows your acceptance criteria; verify the
25
- expected artifact or tests afterward.
20
+ For unattended monitors on existing visible/detached sessions, prefer
21
+ `harnex watch --until done`: it returns on the work-level `task_complete` or
22
+ `task_failed` signal, or terminal exit, whichever comes first. Successful work
23
+ exits `0`, failed work exits non-zero, and wall-clock caps exit `124`. For
24
+ callers that need the lower-level primitive, `harnex wait --until done` exposes
25
+ the same work fence. For structured sessions (Pi RPC and Codex app-server),
26
+ `harnex wait --until task_complete` remains the exact successful-turn fence.
27
+ None of these know your acceptance criteria; verify the expected artifact or
28
+ tests afterward.
26
29
 
27
30
  ## Completion Test
28
31
 
@@ -30,15 +33,19 @@ For unattended work, first gate on harnex work completion, then verify the task
30
33
  artifact and repo health:
31
34
 
32
35
  ```bash
33
- harnex wait --id pi-i-NN --until done --timeout 5400 &&
36
+ harnex watch --id pi-i-NN --until done --max-wait 90m \
37
+ --done-marker /tmp/pi-i-NN-done.json \
38
+ --fail-marker /tmp/pi-i-NN-failed.json &&
34
39
  test -f path/to/expected-artifact &&
35
40
  test -z "$(git status --short)"
36
41
  ```
37
42
 
38
- `harnex wait --until done` succeeds from `task_complete` or durable successful
39
- terminal telemetry (`--summary-out` / `.harnex/dispatch.jsonl` / exit status),
40
- returns non-zero for `task_failed` / failed terminal telemetry, and does not use
41
- tmp done markers.
43
+ `harnex watch --until done` wraps the `harnex wait --until done` work fence:
44
+ it succeeds from `task_complete` or durable successful terminal telemetry
45
+ (`--summary-out` / `.harnex/dispatch.jsonl` / exit status), returns non-zero for
46
+ `task_failed` / failed terminal telemetry, returns `124` for `--max-wait`, and
47
+ only writes done/fail markers as compatibility outputs after harnex has seen a
48
+ terminal work signal.
42
49
 
43
50
  Adjust the artifact path to the task. The point is to avoid declaring done while
44
51
  a worker is between edits or between commits.
@@ -76,42 +83,29 @@ harnex events --id pi-i-NN
76
83
  For task completion:
77
84
 
78
85
  ```bash
86
+ harnex watch --id pi-i-NN --until done --max-wait 15m
87
+ # Primitive equivalent when a script wants raw wait semantics:
79
88
  harnex wait --id pi-i-NN --until done --timeout 900
80
- # Or, when you specifically need the structured turn event:
89
+ # Or, when you specifically need the structured successful-turn event:
81
90
  harnex wait --id pi-i-NN --until task_complete --timeout 900
82
91
  ```
83
92
 
84
93
  ## Background Sweeper
85
94
 
86
- Consumers often run a small shell loop that checks terminal state, then drops
87
- to pane diagnostics only while work is still running. Keep a hard wall-clock cap
88
- so an unattended pipeline cannot wait forever:
95
+ Avoid custom shell loops that repeatedly call `harnex wait`/`harnex status` and
96
+ then accidentally swallow a failed work result. For a single unattended
97
+ visible/detached dispatch, use the native watcher with a hard wall-clock cap:
89
98
 
90
99
  ```bash
91
- start=$(date +%s)
92
- max_wait=5400
93
-
94
- while :; do
95
- if test "$(($(date +%s) - start))" -gt "$max_wait"; then
96
- echo "wall-clock cap hit for pi-i-NN" >&2
97
- exit 2
98
- fi
99
-
100
- row=$(harnex status --id pi-i-NN --json | ruby -rjson -e 'rows=JSON.parse(STDIN.read); print JSON.generate(rows.first || {})')
101
- done=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["done"] ? "true" : "false")')
102
- work_state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["work_state"].to_s)')
103
- state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["state"].to_s)')
104
-
105
- case "$done:$work_state" in
106
- true:*) echo "pi-i-NN work completed"; break ;;
107
- false:failed) echo "pi-i-NN work failed; process state: $state" >&2; exit 1 ;;
108
- *) harnex pane --id pi-i-NN --lines 20 ;;
109
- esac
110
-
111
- sleep 60
112
- done
100
+ harnex watch --id pi-i-NN --until done --max-wait 90m \
101
+ --done-marker /tmp/pi-i-NN-done.json \
102
+ --fail-marker /tmp/pi-i-NN-failed.json
113
103
  ```
114
104
 
105
+ If that exits `124`, inspect the pane/logs/events and decide whether to nudge,
106
+ stop, or continue. If it exits any other non-zero code, treat the work as
107
+ failed; do not continue polling the same task as though it were still running.
108
+
115
109
  Recommended caps:
116
110
 
117
111
  | Work type | Cap |
@@ -120,17 +114,18 @@ Recommended caps:
120
114
  | Medium implementation | 90 minutes |
121
115
  | Large unattended phase | 3 hours |
122
116
 
123
- ## Built-In Watch Mode
117
+ ## Built-In Stall Babysitter
124
118
 
125
119
  Use `harnex run --watch` when one foreground process should launch the worker
126
- and apply bounded stall recovery:
120
+ and apply bounded stall recovery. This is different from `harnex watch --id`,
121
+ which watches an existing session's work-terminal state:
127
122
 
128
123
  ```bash
129
124
  harnex run pi --id pi-i-NN --watch --preset impl \
130
125
  --context "Read /tmp/task-impl-NN.md"
131
126
  ```
132
127
 
133
- `--watch` exits with:
128
+ `run --watch` exits with:
134
129
 
135
130
  | Code | Meaning |
136
131
  | --- | --- |
@@ -145,6 +140,7 @@ interpretation.
145
140
 
146
141
  - Polling `state=completed` alone and missing live sessions with `task_complete=true`.
147
142
  - Polling `state=prompt` alone and calling it done.
143
+ - Wrapping `harnex wait` in loops that swallow non-zero `task_failed` results.
148
144
  - Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
149
145
  - Letting an unattended loop run with no wall-clock cap.
150
146
  - Reading raw tmux panes instead of `harnex pane`.
data/lib/harnex/cli.rb CHANGED
@@ -15,6 +15,8 @@ module Harnex
15
15
  Sender.new(@argv.drop(1)).run
16
16
  when "wait"
17
17
  Waiter.new(@argv.drop(1)).run
18
+ when "watch"
19
+ WatchCommand.new(@argv.drop(1)).run
18
20
  when "stop"
19
21
  Stopper.new(@argv.drop(1)).run
20
22
  when "status"
@@ -59,6 +61,8 @@ module Harnex
59
61
  Sender.usage
60
62
  when "wait"
61
63
  Waiter.usage
64
+ when "watch"
65
+ WatchCommand.usage
62
66
  when "stop"
63
67
  Stopper.usage
64
68
  when "status"
@@ -90,6 +94,7 @@ module Harnex
90
94
  harnex run <cli> [options] [--] [cli-args...]
91
95
  harnex send --id ID [options] [text...]
92
96
  harnex wait --id ID [options]
97
+ harnex watch --id ID [options]
93
98
  harnex stop --id ID [options]
94
99
  harnex status [options]
95
100
  harnex logs --id ID [options]
@@ -104,6 +109,7 @@ module Harnex
104
109
  run Start a wrapped interactive session and local API
105
110
  send Send text to an active session
106
111
  wait Block until a session exits or reaches a state
112
+ watch Safely watch existing work until done/task_failed/timeout
107
113
  stop Send the adapter stop sequence to a session
108
114
  status List live sessions
109
115
  logs Read session output transcripts
@@ -129,6 +135,7 @@ module Harnex
129
135
  harnex run aider --id blue-cat
130
136
  harnex run codex -- --cd /path/to/repo
131
137
  harnex status
138
+ harnex watch --id main --until done --max-wait 15m
132
139
  harnex logs --id main --follow
133
140
  harnex events --id main --snapshot
134
141
  harnex history --limit 20
@@ -8,11 +8,11 @@ module Harnex
8
8
  KNOWN_FLAGS = %w[
9
9
  --id --description --detach --tmux --host --port --watch --watch-file
10
10
  --stall-after --max-resumes --preset --context --meta --summary-out
11
- --timeout --inbox-ttl --auto-stop --fast --legacy-pty --help
11
+ --cwd --root --timeout --inbox-ttl --auto-stop --fast --legacy-pty --help
12
12
  ].freeze
13
13
  VALUE_FLAGS = %w[
14
14
  --id --description --host --port --watch --watch-file --stall-after
15
- --max-resumes --preset --context --meta --summary-out --timeout --inbox-ttl
15
+ --max-resumes --preset --context --meta --summary-out --cwd --root --timeout --inbox-ttl
16
16
  ].freeze
17
17
 
18
18
  def self.usage(program_name = "harnex run")
@@ -37,6 +37,8 @@ module Harnex
37
37
  Default Codex runs force service_tier="flex".
38
38
  --meta JSON Attach parsed JSON metadata to the started event
39
39
  --summary-out PATH Append dispatch telemetry summary JSONL to PATH
40
+ --cwd DIR Run the wrapped agent from DIR and use DIR as the session root
41
+ --root DIR Override harnex session/root attribution without changing child cwd
40
42
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
41
43
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
42
44
  --legacy-pty (codex only) Use the legacy PTY adapter instead of
@@ -58,6 +60,7 @@ module Harnex
58
60
  #{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md"
59
61
  #{program_name} pi --id pi-i-42 --tmux pi-i-42 --context "Read /tmp/task-impl-42.md" --auto-stop
60
62
  #{program_name} pi --id pi-i-42 --watch --preset impl --context "Read /tmp/task-impl-42.md"
63
+ #{program_name} codex --cwd /tmp/public-bundle --id eval-001 --context "Read README.md and write OUTPUT.md" --auto-stop
61
64
  #{program_name} claude --id cl-r-42 --tmux cl-r-42 --description "Review task 42"
62
65
 
63
66
  Gotchas:
@@ -88,6 +91,8 @@ module Harnex
88
91
  context: nil,
89
92
  meta: nil,
90
93
  summary_out: nil,
94
+ cwd: nil,
95
+ root: nil,
91
96
  auto_stop: false,
92
97
  detach: false,
93
98
  tmux: false,
@@ -110,7 +115,7 @@ module Harnex
110
115
  raise OptionParser::MissingArgument, "cli" if cli_name.nil?
111
116
  validate_auto_stop_context!
112
117
 
113
- repo_root = Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
118
+ repo_root = resolve_run_root(cli_name, child_args)
114
119
  @options[:summary_out] = resolve_summary_out(repo_root)
115
120
  @options[:id] ||= Harnex.generate_id(repo_root)
116
121
  validate_unique_id!(repo_root)
@@ -173,6 +178,8 @@ module Harnex
173
178
  tmux_cmd << "--auto-stop" if @options[:auto_stop]
174
179
  tmux_cmd += ["--meta", JSON.generate(@options[:meta])] if @options[:meta]
175
180
  tmux_cmd += ["--summary-out", @options[:summary_out]] if @options[:summary_out]
181
+ tmux_cmd += ["--cwd", @options[:cwd]] if @options[:cwd]
182
+ tmux_cmd += ["--root", @options[:root]] if @options[:root]
176
183
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
177
184
  tmux_cmd << "--fast" if @options[:fast]
178
185
  tmux_cmd += ["--legacy-pty"] if @options[:legacy_pty]
@@ -181,11 +188,12 @@ module Harnex
181
188
  window_name = @options[:tmux_name] || @options[:id]
182
189
  shell_cmd = tmux_cmd.map { |arg| Shellwords.shellescape(arg) }.join(" ")
183
190
 
191
+ tmux_start_cwd = @options[:cwd] || @launch_cwd
184
192
  started =
185
193
  if ENV["TMUX"]
186
- system("tmux", "new-window", "-c", @launch_cwd, "-n", window_name, "-d", shell_cmd)
194
+ system("tmux", "new-window", "-c", tmux_start_cwd, "-n", window_name, "-d", shell_cmd)
187
195
  else
188
- system("tmux", "new-session", "-c", @launch_cwd, "-d", "-s", "harnex", "-n", window_name, shell_cmd)
196
+ system("tmux", "new-session", "-c", tmux_start_cwd, "-d", "-s", "harnex", "-n", window_name, shell_cmd)
189
197
  end
190
198
 
191
199
  raise "tmux failed to start #{cli_name.inspect}" unless started
@@ -281,7 +289,8 @@ module Harnex
281
289
  summary_out: @options[:summary_out],
282
290
  inbox_ttl: @options[:inbox_ttl],
283
291
  auto_stop: @options[:auto_stop],
284
- launch_cwd: @launch_cwd
292
+ launch_cwd: history_cwd,
293
+ child_cwd: session_child_cwd
285
294
  )
286
295
  end
287
296
 
@@ -289,6 +298,24 @@ module Harnex
289
298
  Harnex.build_adapter(cli_name, child_args, legacy_pty: @options[:legacy_pty]).infer_repo_path(child_args)
290
299
  end
291
300
 
301
+ def resolve_run_root(cli_name, child_args)
302
+ return @options[:root] if @options[:root]
303
+ return @options[:cwd] if @options[:cwd]
304
+
305
+ Harnex.resolve_repo_root(adapter_repo_path(cli_name, child_args))
306
+ end
307
+
308
+ def history_cwd
309
+ @options[:root] || @options[:cwd] || @launch_cwd
310
+ end
311
+
312
+ def session_child_cwd
313
+ return @options[:cwd] if @options[:cwd]
314
+ return @launch_cwd if @options[:root]
315
+
316
+ nil
317
+ end
318
+
292
319
  def apply_context(child_args)
293
320
  return child_args unless @options[:context]
294
321
 
@@ -441,6 +468,16 @@ module Harnex
441
468
  @options[:summary_out] = required_option_value(arg, argv[index])
442
469
  when /\A--summary-out=(.+)\z/
443
470
  @options[:summary_out] = required_option_value("--summary-out", Regexp.last_match(1))
471
+ when "--cwd"
472
+ index += 1
473
+ @options[:cwd] = expand_existing_directory(required_option_value(arg, argv[index]), option_name: arg)
474
+ when /\A--cwd=(.+)\z/
475
+ @options[:cwd] = expand_existing_directory(required_option_value("--cwd", Regexp.last_match(1)), option_name: "--cwd")
476
+ when "--root"
477
+ index += 1
478
+ @options[:root] = expand_existing_directory(required_option_value(arg, argv[index]), option_name: arg)
479
+ when /\A--root=(.+)\z/
480
+ @options[:root] = expand_existing_directory(required_option_value("--root", Regexp.last_match(1)), option_name: "--root")
444
481
  when "--timeout"
445
482
  index += 1
446
483
  @options[:timeout] = Float(required_option_value(arg, argv[index]))
@@ -509,7 +546,7 @@ module Harnex
509
546
  nil
510
547
  when *VALUE_FLAGS
511
548
  index += 1
512
- when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|timeout|inbox-ttl)=/
549
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|meta|summary-out|cwd|root|timeout|inbox-ttl)=/
513
550
  nil
514
551
  when /\A--preset=/
515
552
  nil
@@ -528,7 +565,7 @@ module Harnex
528
565
  arg.start_with?(
529
566
  "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
530
567
  "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--meta=", "--summary-out=",
531
- "--timeout=", "--inbox-ttl="
568
+ "--cwd=", "--root=", "--timeout=", "--inbox-ttl="
532
569
  )
533
570
  end
534
571
 
@@ -585,6 +622,13 @@ module Harnex
585
622
  raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
586
623
  end
587
624
 
625
+ def expand_existing_directory(value, option_name:)
626
+ path = File.expand_path(value.to_s, @launch_cwd)
627
+ return path if File.directory?(path)
628
+
629
+ raise OptionParser::InvalidArgument, "#{option_name} must be an existing directory: #{value}"
630
+ end
631
+
588
632
  def parse_meta(value)
589
633
  parsed = JSON.parse(value)
590
634
  return parsed if parsed.is_a?(Hash)
@@ -1,5 +1,8 @@
1
+ require "fileutils"
1
2
  require "json"
2
3
  require "net/http"
4
+ require "optparse"
5
+ require "stringio"
3
6
  require "uri"
4
7
 
5
8
  module Harnex
@@ -206,4 +209,205 @@ module Harnex
206
209
  @monotonic_clock.call
207
210
  end
208
211
  end
212
+
213
+ class TerminalWatcher
214
+ TIMEOUT_EXIT_CODE = 124
215
+
216
+ def initialize(
217
+ id:,
218
+ repo_path: Dir.pwd,
219
+ until_state: "done",
220
+ max_wait: nil,
221
+ done_marker: nil,
222
+ fail_marker: nil,
223
+ stop_on_terminal: false,
224
+ out: $stdout,
225
+ err: $stderr
226
+ )
227
+ @id = Harnex.normalize_id(id)
228
+ @repo_path = repo_path
229
+ @until_state = until_state.to_s.strip.empty? ? "done" : until_state.to_s
230
+ @max_wait = max_wait
231
+ @done_marker = done_marker
232
+ @fail_marker = fail_marker
233
+ @stop_on_terminal = stop_on_terminal
234
+ @out = out
235
+ @err = err
236
+ end
237
+
238
+ def run
239
+ raise "harnex watch: only --until done is supported" unless @until_state == "done"
240
+
241
+ output, warnings, exit_code = capture_wait
242
+ @err.write(warnings) unless warnings.empty?
243
+ @out.write(output) unless output.empty?
244
+
245
+ payload = parse_payload(output)
246
+ outcome = classify(exit_code, payload)
247
+ case outcome
248
+ when :success
249
+ write_marker(@done_marker, payload, outcome: outcome, exit_code: exit_code)
250
+ when :failed
251
+ write_marker(@fail_marker, payload, outcome: outcome, exit_code: exit_code)
252
+ end
253
+
254
+ stop_session if @stop_on_terminal && outcome != :timeout
255
+ exit_code
256
+ end
257
+
258
+ private
259
+
260
+ def capture_wait
261
+ argv = ["--id", @id, "--repo", @repo_path, "--until", @until_state]
262
+ argv += ["--timeout", @max_wait.to_s] if @max_wait
263
+
264
+ out_buffer = StringIO.new
265
+ err_buffer = StringIO.new
266
+ original_stdout = $stdout
267
+ original_stderr = $stderr
268
+ $stdout = out_buffer
269
+ $stderr = err_buffer
270
+ exit_code = Waiter.new(argv).run
271
+ [out_buffer.string, err_buffer.string, exit_code]
272
+ ensure
273
+ $stdout = original_stdout
274
+ $stderr = original_stderr
275
+ end
276
+
277
+ def parse_payload(output)
278
+ line = output.to_s.lines.reverse.find { |candidate| !candidate.strip.empty? }
279
+ return {} unless line
280
+
281
+ parsed = JSON.parse(line)
282
+ parsed.is_a?(Hash) ? parsed : {}
283
+ rescue JSON::ParserError
284
+ {}
285
+ end
286
+
287
+ def classify(exit_code, payload)
288
+ return :timeout if exit_code == TIMEOUT_EXIT_CODE || payload["status"].to_s == "timeout"
289
+ return :success if exit_code.to_i.zero? && (payload.empty? || payload["ok"] != false)
290
+
291
+ :failed
292
+ end
293
+
294
+ def write_marker(path, payload, outcome:, exit_code:)
295
+ marker_path = path.to_s.strip
296
+ return if marker_path.empty?
297
+
298
+ expanded_path = File.expand_path(marker_path)
299
+ FileUtils.mkdir_p(File.dirname(expanded_path))
300
+ marker_payload = {
301
+ ok: outcome == :success,
302
+ id: @id,
303
+ outcome: outcome.to_s,
304
+ exit_code: exit_code,
305
+ status: payload["status"],
306
+ work_state: payload["work_state"],
307
+ task_complete: payload["task_complete"] || payload["event"] == "task_complete",
308
+ task_failed: payload["task_failed"] || payload["event"] == "task_failed",
309
+ done: payload["done"],
310
+ terminal: payload["terminal"],
311
+ source: "harnex watch"
312
+ }.compact
313
+ File.write(expanded_path, JSON.generate(marker_payload) + "\n")
314
+ end
315
+
316
+ def stop_session
317
+ repo_root = Harnex.resolve_repo_root(@repo_path)
318
+ registry = Harnex.read_registry(repo_root, @id)
319
+ return unless registry
320
+
321
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/stop")
322
+ request = Net::HTTP::Post.new(uri)
323
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
324
+
325
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 2) do |http|
326
+ http.request(request)
327
+ end
328
+ @err.puts("harnex watch: stop-on-terminal failed with HTTP #{response.code}") unless response.is_a?(Net::HTTPSuccess)
329
+ rescue StandardError => e
330
+ @err.puts("harnex watch: stop-on-terminal failed: #{e.message}")
331
+ end
332
+ end
333
+
334
+ class WatchCommand
335
+ def self.usage(program_name = "harnex watch")
336
+ <<~TEXT
337
+ Usage: #{program_name} --id ID [options]
338
+
339
+ Options:
340
+ --id ID Existing session ID to watch (required)
341
+ --until done Watch work-level terminal state (default: done)
342
+ --repo PATH Resolve session using PATH's repo root (default: current repo)
343
+ --max-wait DUR Wall-clock cap before returning timeout (examples: 900, 15m, 2h)
344
+ --timeout DUR Alias for --max-wait
345
+ --done-marker PATH Write a JSON marker when work completes successfully
346
+ --fail-marker PATH Write a JSON marker when work fails
347
+ --stop-on-terminal Stop the live session after success/failure (not on timeout)
348
+ -h, --help Show this help
349
+
350
+ `harnex watch` is the safe watcher for existing --tmux or detached
351
+ dispatches. It exits 0 for task_complete/done, non-zero for task_failed
352
+ or failed terminal summaries, and 124 for --max-wait timeouts.
353
+
354
+ For launch-and-babysit stall recovery, use `harnex run --watch`.
355
+ TEXT
356
+ end
357
+
358
+ def initialize(argv)
359
+ @argv = argv.dup
360
+ @options = {
361
+ id: nil,
362
+ repo_path: Dir.pwd,
363
+ until_state: "done",
364
+ max_wait: nil,
365
+ done_marker: nil,
366
+ fail_marker: nil,
367
+ stop_on_terminal: false,
368
+ help: false
369
+ }
370
+ end
371
+
372
+ def run
373
+ parser.parse!(@argv)
374
+ if @options[:help]
375
+ puts self.class.usage
376
+ return 0
377
+ end
378
+
379
+ raise "--id is required for harnex watch" unless @options[:id]
380
+
381
+ TerminalWatcher.new(
382
+ id: @options[:id],
383
+ repo_path: @options[:repo_path],
384
+ until_state: @options[:until_state],
385
+ max_wait: @options[:max_wait],
386
+ done_marker: @options[:done_marker],
387
+ fail_marker: @options[:fail_marker],
388
+ stop_on_terminal: @options[:stop_on_terminal]
389
+ ).run
390
+ end
391
+
392
+ private
393
+
394
+ def parser
395
+ @parser ||= OptionParser.new do |opts|
396
+ opts.banner = "Usage: harnex watch --id ID [options]"
397
+ opts.on("--id ID", "Existing session ID to watch") { |value| @options[:id] = Harnex.normalize_id(value) }
398
+ opts.on("--until STATE", "Watch until terminal state") { |value| @options[:until_state] = value }
399
+ opts.on("--repo PATH", "Resolve session using PATH's repo root") { |value| @options[:repo_path] = value }
400
+ opts.on("--max-wait DUR", "Wall-clock cap") do |value|
401
+ @options[:max_wait] = Harnex.parse_duration_seconds(value, option_name: "--max-wait")
402
+ end
403
+ opts.on("--timeout DUR", "Alias for --max-wait") do |value|
404
+ @options[:max_wait] = Harnex.parse_duration_seconds(value, option_name: "--timeout")
405
+ end
406
+ opts.on("--done-marker PATH", "Write marker on successful completion") { |value| @options[:done_marker] = value }
407
+ opts.on("--fail-marker PATH", "Write marker on failed completion") { |value| @options[:fail_marker] = value }
408
+ opts.on("--stop-on-terminal", "Stop live session after success/failure") { @options[:stop_on_terminal] = true }
409
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
410
+ end
411
+ end
412
+ end
209
413
  end
data/lib/harnex/core.rb CHANGED
@@ -32,7 +32,7 @@ module Harnex
32
32
  ].freeze
33
33
 
34
34
  def resolve_repo_root(path = Dir.pwd)
35
- output, status = Open3.capture2("git", "rev-parse", "--show-toplevel", chdir: path)
35
+ output, status = Open3.capture2("git", "rev-parse", "--show-toplevel", chdir: path, err: File::NULL)
36
36
  status.success? ? output.strip : File.expand_path(path)
37
37
  rescue StandardError
38
38
  File.expand_path(path)
@@ -58,15 +58,16 @@ module Harnex
58
58
  end
59
59
  end
60
60
 
61
- attr_reader :repo_root, :launch_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
61
+ attr_reader :repo_root, :launch_cwd, :child_cwd, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch,
62
62
  :inbox, :description, :meta, :summary_out, :output_log_path, :events_log_path,
63
63
  :started_at, :ended_at, :exit_code, :term_signal
64
64
 
65
- def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil)
65
+ def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, meta: nil, summary_out: nil, inbox_ttl: Inbox::DEFAULT_TTL, auto_stop: false, launch_cwd: nil, child_cwd: nil)
66
66
  @adapter = adapter
67
67
  @command = command
68
68
  @repo_root = repo_root
69
69
  @launch_cwd = File.expand_path(launch_cwd.to_s.empty? ? repo_root : launch_cwd)
70
+ @child_cwd = child_cwd.to_s.empty? ? nil : File.expand_path(child_cwd)
70
71
  @host = host
71
72
  @id = Harnex.normalize_id(id)
72
73
  @watch = watch
@@ -153,7 +154,9 @@ module Harnex
153
154
  end
154
155
 
155
156
  def run_pty
156
- @reader, @writer, @pid = PTY.spawn(child_env, *command)
157
+ spawn_args = [child_env, *command]
158
+ spawn_args << { chdir: child_cwd } if child_cwd
159
+ @reader, @writer, @pid = PTY.spawn(*spawn_args)
157
160
  @writer.sync = true
158
161
  arm_auto_stop_after_initial_context
159
162
  emit_started_event
@@ -402,7 +405,7 @@ module Harnex
402
405
  adapter.on_notification { |msg| handle_structured_notification(msg) }
403
406
  adapter.on_disconnect { |err| handle_structured_disconnect(err) }
404
407
 
405
- adapter.start_rpc(env: child_env, cwd: repo_root)
408
+ adapter.start_rpc(env: child_env, cwd: child_cwd || repo_root)
406
409
  @pid = adapter.pid
407
410
  @state_machine.force_prompt!
408
411
  emit_started_event
@@ -821,7 +824,7 @@ module Harnex
821
824
  def registry_payload
822
825
  status_payload(include_input_state: false).merge(
823
826
  token: token,
824
- cwd: Dir.pwd
827
+ cwd: child_cwd || Dir.pwd
825
828
  )
826
829
  end
827
830
 
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.7"
3
- RELEASE_DATE = "2026-06-12"
2
+ VERSION = "0.7.9"
3
+ RELEASE_DATE = "2026-07-10"
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.7.7
4
+ version: 0.7.9
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-06-12 00:00:00.000000000 Z
11
+ date: 2026-07-10 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.