harnex 0.7.5 → 0.7.6

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: 4c6173057b1fe4fa547979d3d9803f345acb4e658dd942aefa096ef886727c33
4
- data.tar.gz: da1662c9dce791ae644aa4a0ea57d2124a1698593e6db98b2ac737d9f42816f6
3
+ metadata.gz: f29114f723ccfc61ade344c784cb6a11144c25e79392bf136f5b722473b47f0b
4
+ data.tar.gz: 810686487788509887bb8bfb5f9a4c45a8a1b8bca645530b1bf178f1435cee40
5
5
  SHA512:
6
- metadata.gz: e2758cfa10fc9b221235efea20ff21be32b5558c1ab06eb4515cea5485be96e1371d663735c826d9587833caa46aed65486b5c3cedfe8ce3be04e7ac1c328f7f
7
- data.tar.gz: 765cf4ef020f3a7cfa8bbd1aad517ac1b4ec8901579b3720ea618ec246b20009c07c088d4d86e354ead78194844bba771790df0271d54fccecad6f6058874d9f
6
+ metadata.gz: 90c68832ab9218716fd2e6181f006a12b996a23c28b120a769411618aee35e507c38df43bcf9293a98fb3902923e70eb1be9e08a90795e9ec970cb5b51ca2b54
7
+ data.tar.gz: 64ebab38e3cf716bfe69fd7f8d1b28c0d114ef72e81f8028cb87f0b83f6a75cb555e1036a7831a41b3dddd156c0b9f71e51fa9550271f5be529dcea1fd23c9e1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.6] - 2026-06-09 | 12:59 AM | IST
6
+
7
+ ### Added
8
+
9
+ - `harnex status --json` now exposes work-level completion fields (`done`,
10
+ `work_state`, and `process_state`) so monitors can distinguish completed
11
+ work from a still-live interactive process.
12
+ - `harnex wait --until done` waits for `task_complete` or terminal exit,
13
+ whichever arrives first, giving queue monitors a safe default fence.
14
+
15
+ ### Changed
16
+
17
+ - Refreshed README quick-start and monitoring guidance to emphasize
18
+ `--context --auto-stop`, `--until task_complete` for interactive structured
19
+ sessions, durable terminal summaries, and timeout/artifact verification.
20
+ - Monitoring, buddy, and recipe examples now gate unattended work on
21
+ `--until done` / `done` / `work_state` instead of `state=completed` alone.
22
+
5
23
  ## [0.7.5] - 2026-05-26 | 05:18 PM | IST
6
24
 
7
25
  ### Added
data/README.md CHANGED
@@ -33,16 +33,24 @@ or project-local docs are required.
33
33
  ## What it does
34
34
 
35
35
  ```bash
36
- # Start an agent in tmux
37
- harnex run pi --id planner --tmux planner
36
+ # Start a visible one-shot worker, give it a task, and stop on completion
37
+ harnex run pi --id planner --tmux planner \
38
+ --context "Write a plan to /tmp/plan.md" --auto-stop
38
39
 
39
- # Send it a task and wait for it to finish
40
- harnex send --id planner --message "Write a plan to /tmp/plan.md" --wait-for-idle
40
+ # Wait for the work-level completion signal and terminal telemetry
41
+ harnex wait --id planner --until done --timeout 900
41
42
 
42
- # Peek at what it's doing
43
- harnex pane --id planner --lines 30
43
+ # Inspect the final state or the recent dispatch history
44
+ harnex status --id planner --json
45
+ harnex history --limit 5
46
+ ```
47
+
48
+ For a long-lived interactive session, keep the worker open and send work to it:
44
49
 
45
- # Stop it
50
+ ```bash
51
+ harnex run pi --id planner --tmux planner
52
+ harnex send --id planner --message "Write a plan to /tmp/plan.md" --wait-for-idle
53
+ harnex pane --id planner --lines 30
46
54
  harnex stop --id planner
47
55
  ```
48
56
 
@@ -59,8 +67,9 @@ job, watch it work, stop it when done.
59
67
  tmux-backed terminal, `harnex logs` tails the persisted transcript, and
60
68
  `harnex events` streams structured JSONL lifecycle events.
61
69
 
62
- - **You don't want to babysit.** Send a task with `--wait-for-idle`,
63
- walk away, check back when it's done.
70
+ - **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.
64
73
 
65
74
  - **You want local-only orchestration.** Everything runs on your
66
75
  machine. No cloud services, no API keys beyond what the agents need.
@@ -169,6 +178,30 @@ bare `--watch` means babysitter mode.
169
178
  For one-shot startup prompts, add `--auto-stop`. It requires `--context`
170
179
  and stops the session after the first task completion or PTY prompt return.
171
180
 
181
+ ## Completion and waiting
182
+
183
+ Choose the wait predicate that matches how you launched the worker:
184
+
185
+ - `harnex wait --id ID --until done --timeout SECS` is the safest unattended
186
+ work fence. It returns when Harnex sees `task_complete` or a terminal exit,
187
+ whichever comes first.
188
+ - `harnex wait --id ID` waits for the wrapped process to exit. This is right
189
+ for already-exited sessions and terminal-summary recovery, but interactive
190
+ agents can stay open after finishing a turn.
191
+ - For structured Pi RPC and Codex app-server sessions, use
192
+ `harnex wait --id ID --until task_complete --timeout SECS` when you need the
193
+ exact turn-completion event instead of terminal-exit fallback.
194
+ - `harnex send --wait-for-idle` is an atomic send fence for PTY-style
195
+ interactions. It proves the turn returned to an idle/prompt state, not that
196
+ your acceptance criteria passed.
197
+ - `harnex status --id ID --json` can report `running`, `completed`, `failed`,
198
+ or `unknown` from durable terminal summary rows even after the live registry
199
+ is gone. Treat `state` as process/session state; use `done` and `work_state`
200
+ as the work-level monitor contract.
201
+
202
+ Always set a timeout for unattended waits and verify the expected artifact,
203
+ tests, or git state after harnex reports completion.
204
+
172
205
  For structured subscriptions, stream JSONL events:
173
206
 
174
207
  ```bash
@@ -183,7 +216,9 @@ Schema details and compatibility policy are documented in
183
216
  Every finished `harnex run` writes dispatch records. In a git repo, the
184
217
  default path is `<repo>/.harnex/dispatch.jsonl`; outside a git repo, the
185
218
  compact history record falls back to `~/.local/state/harnex/dispatch.jsonl`.
186
- `harnex history` reads the compact records from that location.
219
+ `harnex history` reads the compact records from that location, and
220
+ `harnex status --id ID --json` / `harnex wait` can use the same durable
221
+ terminal summaries when the live session registry is already gone.
187
222
 
188
223
  Use `harnex history` to inspect it:
189
224
 
@@ -274,14 +309,14 @@ See [recipes/03_buddy.md](recipes/03_buddy.md) for the full pattern.
274
309
  | Command | What it does |
275
310
  |---------|-------------|
276
311
  | `harnex run <cli>` | Start an agent (`--tmux` visible, `--detach` background, `--watch` built-in monitoring) |
277
- | `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until done) |
312
+ | `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until the turn returns idle) |
278
313
  | `harnex stop --id <id>` | Send the agent's native exit sequence |
279
- | `harnex status` | List running sessions (`--json` for full payloads) |
314
+ | `harnex status` | List running sessions; with `--id ID --json`, terminal summaries can classify completed/failed sessions after exit |
280
315
  | `harnex pane --id <id>` | Capture a tmux-backed session's screen (`--follow` for live) |
281
316
  | `harnex logs --id <id>` | Read session transcript (`--follow` to tail) |
282
317
  | `harnex events --id <id>` | Stream structured session events (`--snapshot` for non-blocking dump) |
283
318
  | `harnex history` | List completed dispatches from `.harnex/dispatch.jsonl` |
284
- | `harnex wait --id <id>` | Block until exit, a target state, or `--until task_complete` |
319
+ | `harnex wait --id <id>` | Block until process exit by default; use `--until done` for unattended work completion or `--until task_complete` for exact structured turn completion |
285
320
  | `harnex doctor` | Run adapter dependency preflight checks; add `--sweep` for read-only session drift diagnostics |
286
321
  | `harnex guide` | Getting started walkthrough |
287
322
  | `harnex agents-guide` | Agent-facing dispatch, chain, buddy, monitoring, and naming guides |
@@ -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 & done
76
+ for i in 1 2 3; do harnex wait --id w-$i --until done & done
77
77
  wait
78
78
  ```
79
79
 
@@ -125,6 +125,7 @@ Use the lightest primitive that gives the signal you need:
125
125
  | Continuous pane view | `harnex pane --id pi-i-NN --follow` |
126
126
  | Transcript tail | `harnex logs --id pi-i-NN --lines 80` |
127
127
  | Structured events | `harnex events --id pi-i-NN --snapshot` |
128
+ | Work completion fence | `harnex wait --id pi-i-NN --until done` |
128
129
  | Native turn completion | `harnex wait --id pi-i-NN --until task_complete` |
129
130
 
130
131
  For unattended policy-only stall recovery, use built-in watch mode:
data/guides/03_buddy.md CHANGED
@@ -54,8 +54,8 @@ If the worker appears stuck at a prompt or permission dialog for more than
54
54
  10 minutes with no progress, nudge it:
55
55
  - `harnex send --id pi-i-42 --message "You appear to have stalled. Continue with your current task."`
56
56
 
57
- If `harnex status --id pi-i-42 --json` reports `state=completed` or `state=failed`, report back:
58
- - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 terminal state reached. Check harnex status --id pi-i-42 --json." Enter`
57
+ If `harnex status --id pi-i-42 --json` reports `done=true` or `work_state=failed`, report back:
58
+ - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 work-level state reached. Check harnex status --id pi-i-42 --json." Enter`
59
59
 
60
60
  Do not interfere with active work. Stop yourself after reporting completion.
61
61
  ```
@@ -17,24 +17,26 @@ 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 structured sessions (Pi RPC and Codex app-server),
21
- `harnex wait --until task_complete` is a strong turn-level fence. It still
22
- does not know your acceptance criteria; verify the expected artifact or tests
23
- afterward.
20
+ For unattended monitors, prefer `harnex wait --until done`: it returns on the
21
+ work-level `task_complete` signal or terminal exit, whichever comes first. For
22
+ structured sessions (Pi RPC and Codex app-server), `harnex wait --until
23
+ task_complete` remains the exact turn-level fence. Neither knows your acceptance
24
+ criteria; verify the expected artifact or tests afterward.
24
25
 
25
26
  ## Completion Test
26
27
 
27
- For unattended work, first gate on harnex terminal state, then verify the task
28
+ For unattended work, first gate on harnex work completion, then verify the task
28
29
  artifact and repo health:
29
30
 
30
31
  ```bash
31
- harnex wait --id pi-i-NN --timeout 5400 &&
32
+ harnex wait --id pi-i-NN --until done --timeout 5400 &&
32
33
  test -f path/to/expected-artifact &&
33
34
  test -z "$(git status --short)"
34
35
  ```
35
36
 
36
- `harnex wait` succeeds from durable terminal telemetry (`--summary-out` /
37
- `.harnex/dispatch.jsonl` / exit status), not from tmp done markers.
37
+ `harnex wait --until done` succeeds from `task_complete` or durable terminal
38
+ telemetry (`--summary-out` / `.harnex/dispatch.jsonl` / exit status), not from
39
+ tmp done markers.
38
40
 
39
41
  Adjust the artifact path to the task. The point is to avoid declaring done while
40
42
  a worker is between edits or between commits.
@@ -72,6 +74,8 @@ harnex events --id pi-i-NN
72
74
  For task completion:
73
75
 
74
76
  ```bash
77
+ harnex wait --id pi-i-NN --until done --timeout 900
78
+ # Or, when you specifically need the structured turn event:
75
79
  harnex wait --id pi-i-NN --until task_complete --timeout 900
76
80
  ```
77
81
 
@@ -92,12 +96,13 @@ while :; do
92
96
  fi
93
97
 
94
98
  row=$(harnex status --id pi-i-NN --json | ruby -rjson -e 'rows=JSON.parse(STDIN.read); print JSON.generate(rows.first || {})')
99
+ done=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["done"] ? "true" : "false")')
100
+ work_state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["work_state"].to_s)')
95
101
  state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["state"].to_s)')
96
102
 
97
- case "$state" in
98
- completed) echo "pi-i-NN completed"; break ;;
99
- failed|unknown) echo "pi-i-NN terminal state: $state" >&2; exit 1 ;;
100
- running) harnex pane --id pi-i-NN --lines 20 ;;
103
+ case "$done:$work_state" in
104
+ true:*) echo "pi-i-NN work completed"; break ;;
105
+ false:failed) echo "pi-i-NN work failed; process state: $state" >&2; exit 1 ;;
101
106
  *) harnex pane --id pi-i-NN --lines 20 ;;
102
107
  esac
103
108
 
@@ -136,6 +141,7 @@ interpretation.
136
141
 
137
142
  ## Anti-Patterns
138
143
 
144
+ - Polling `state=completed` alone and missing live sessions with `task_complete=true`.
139
145
  - Polling `state=prompt` alone and calling it done.
140
146
  - Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
141
147
  - Letting an unattended loop run with no wall-clock cap.
@@ -30,6 +30,8 @@ module Harnex
30
30
  Use --all when supervising workers launched from sibling worktrees.
31
31
  With --id, terminal summaries can report completed/failed/unknown
32
32
  even after the live session registry is gone.
33
+ `state` is process/session state; use JSON `done`/`work_state`
34
+ or `harnex wait --until done` for work-level completion.
33
35
  A prompt-like state is not a completion signal by itself.
34
36
  TEXT
35
37
  end
@@ -100,10 +102,14 @@ module Harnex
100
102
  end
101
103
 
102
104
  def normalize_live_status(session)
105
+ task_complete = task_complete?(session)
103
106
  session.merge(
104
107
  "state" => "running",
108
+ "process_state" => "running",
105
109
  "terminal" => false,
106
- "task_complete" => !session["last_completed_at"].to_s.empty?,
110
+ "task_complete" => task_complete,
111
+ "done" => Harnex.work_done_for("running", task_complete: task_complete),
112
+ "work_state" => Harnex.work_state_for("running", task_complete: task_complete),
107
113
  "exit" => nil,
108
114
  "exit_code" => nil,
109
115
  "summary_out" => nil,
@@ -112,6 +118,11 @@ module Harnex
112
118
  )
113
119
  end
114
120
 
121
+ def task_complete?(session)
122
+ session["task_complete"] == true || session["task_complete"].to_s == "true" ||
123
+ !session["last_completed_at"].to_s.empty?
124
+ end
125
+
115
126
  def load_live_status(session)
116
127
  uri = URI("http://#{session.fetch('host')}:#{session.fetch('port')}/status")
117
128
  request = Net::HTTP::Get.new(uri)
@@ -21,6 +21,8 @@ module Harnex
21
21
  Options:
22
22
  --id ID Session ID to wait for (required)
23
23
  --until STATE Wait until session reaches STATE. Supported:
24
+ done (work fence — task_complete or
25
+ terminal exit, whichever comes first)
24
26
  task_complete (events JSONL — fires on
25
27
  turn/completed; adapter-agnostic)
26
28
  <other> (agent_state HTTP poll, e.g.
@@ -31,11 +33,13 @@ module Harnex
31
33
  -h, --help Show this help
32
34
 
33
35
  Common patterns:
36
+ #{program_name} --id cx-i-42 --until done --timeout 900
34
37
  #{program_name} --id cx-i-42 --until task_complete --timeout 900
35
38
  #{program_name} --id cx-i-42 --until prompt --timeout 120
36
39
  #{program_name} --id cx-i-42
37
40
 
38
41
  Gotchas:
42
+ done is the safest work-level fence for monitors.
39
43
  task_complete is an event predicate; prompt/busy are live state polls.
40
44
  Prompt state alone does not prove work acceptance. Verify artifacts/tests.
41
45
  Exit waits can resolve from terminal summary rows when live registry/
@@ -65,7 +69,10 @@ module Harnex
65
69
  raise "--id is required for harnex wait" unless @options[:id]
66
70
 
67
71
  if @options[:until_state]
68
- if EVENT_PREDICATES.include?(@options[:until_state])
72
+ case @options[:until_state]
73
+ when "done"
74
+ wait_until_done
75
+ when *EVENT_PREDICATES
69
76
  wait_until_event(@options[:until_state])
70
77
  else
71
78
  wait_until_state
@@ -135,7 +142,7 @@ module Harnex
135
142
 
136
143
  task_complete_seen = true if event_type(event) == "task_complete"
137
144
  if matches?(event, predicate, task_complete_seen)
138
- return [emit_event_match(event, start_time), f.pos, task_complete_seen]
145
+ return [emit_event_match(event, start_time, predicate), f.pos, task_complete_seen]
139
146
  end
140
147
  end
141
148
  offset = f.pos
@@ -166,7 +173,7 @@ module Harnex
166
173
  def matches?(event, predicate, task_complete_seen)
167
174
  type = event_type(event)
168
175
  case predicate
169
- when "task_complete"
176
+ when "task_complete", "done"
170
177
  type == "task_complete"
171
178
  when "prompt"
172
179
  type == "task_complete" ||
@@ -176,18 +183,100 @@ module Harnex
176
183
  end
177
184
  end
178
185
 
179
- def emit_event_match(event, start_time)
186
+ def emit_event_match(event, start_time, predicate)
180
187
  waited = (Time.now - start_time).round(1)
181
- puts JSON.generate(
188
+ payload = {
182
189
  ok: true,
183
190
  id: @options[:id],
184
191
  event: event_type(event),
185
192
  seq: event["seq"],
186
193
  waited_seconds: waited
187
- )
194
+ }
195
+ if predicate == "done"
196
+ payload.merge!(
197
+ status: "done",
198
+ state: "running",
199
+ process_state: "running",
200
+ terminal: false,
201
+ task_complete: true,
202
+ done: true,
203
+ work_state: "completed"
204
+ )
205
+ end
206
+ puts JSON.generate(payload)
188
207
  0
189
208
  end
190
209
 
210
+ def wait_until_done
211
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
212
+ events_path = Harnex.events_log_path(repo_root, @options[:id])
213
+ exit_path = Harnex.exit_status_path(repo_root, @options[:id])
214
+ registry = Harnex.read_registry(repo_root, @options[:id])
215
+ start_time = Time.now
216
+ deadline = @options[:timeout] ? start_time + @options[:timeout] : nil
217
+
218
+ offset = 0
219
+ task_complete_seen = false
220
+ final_event_deadline = nil
221
+
222
+ status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
223
+ return status if status
224
+
225
+ unless registry
226
+ terminal = done_status(repo_root)
227
+ return emit_done_terminal_status(terminal) if terminal
228
+ return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
229
+
230
+ unless File.exist?(events_path)
231
+ warn("harnex wait: no session found with id #{@options[:id].inspect}")
232
+ puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown", terminal: false,
233
+ task_complete: false, done: false, work_state: "unknown", status: "unknown")
234
+ return 1
235
+ end
236
+ end
237
+
238
+ target_pid = registry && registry["pid"]
239
+
240
+ loop do
241
+ status, offset, task_complete_seen = scan_events(events_path, offset, "done", task_complete_seen, start_time)
242
+ return status if status
243
+
244
+ unless registry
245
+ terminal = done_status(repo_root)
246
+ return emit_done_terminal_status(terminal) if terminal
247
+ return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
248
+ end
249
+
250
+ if deadline && Time.now >= deadline
251
+ waited = (Time.now - start_time).round(1)
252
+ puts JSON.generate(ok: false, id: @options[:id], status: "timeout", waited_seconds: waited,
253
+ done: false, work_state: "running")
254
+ return 124
255
+ end
256
+
257
+ if target_pid && !Harnex.alive_pid?(target_pid)
258
+ final_event_deadline ||= Time.now + FINAL_EVENT_GRACE_SECONDS
259
+ if Time.now >= final_event_deadline
260
+ await_exit_status(exit_path)
261
+ return emit_done_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
262
+
263
+ terminal = done_status(repo_root)
264
+ return emit_done_terminal_status(terminal) if terminal
265
+
266
+ waited = (Time.now - start_time).round(1)
267
+ puts JSON.generate(ok: false, id: @options[:id], state: "exited", process_state: "exited",
268
+ terminal: true, task_complete: false, done: false, work_state: "unknown",
269
+ waited_seconds: waited)
270
+ return 1
271
+ end
272
+ else
273
+ final_event_deadline = nil
274
+ end
275
+
276
+ sleep EVENT_POLL_INTERVAL
277
+ end
278
+ end
279
+
191
280
  def wait_until_state
192
281
  repo_root = Harnex.resolve_repo_root(@options[:repo_path])
193
282
  target_state = @options[:until_state]
@@ -244,7 +333,8 @@ module Harnex
244
333
  return emit_terminal_status(terminal) if terminal
245
334
 
246
335
  warn("harnex wait: no session found with id #{@options[:id].inspect}")
247
- puts JSON.generate(ok: false, id: @options[:id], state: "unknown", terminal: false, status: "unknown")
336
+ puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown",
337
+ terminal: false, task_complete: false, done: false, work_state: "unknown", status: "unknown")
248
338
  return 1
249
339
  end
250
340
 
@@ -259,7 +349,8 @@ module Harnex
259
349
  terminal = terminal_status(repo_root)
260
350
  return emit_terminal_status(terminal) if terminal
261
351
 
262
- puts JSON.generate(ok: false, id: @options[:id], state: "unknown", terminal: false, status: "unknown")
352
+ puts JSON.generate(ok: false, id: @options[:id], state: "unknown", process_state: "unknown",
353
+ terminal: false, task_complete: false, done: false, work_state: "unknown", status: "unknown")
263
354
  return 1
264
355
  end
265
356
 
@@ -328,19 +419,60 @@ module Harnex
328
419
  status
329
420
  end
330
421
 
422
+ def done_status(repo_root)
423
+ status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
424
+ return nil unless status
425
+ return nil unless status["done"] || status["terminal"]
426
+
427
+ status
428
+ end
429
+
430
+ def emit_done_exit_status(exit_path, id)
431
+ data = JSON.parse(File.read(exit_path))
432
+ exit_code = data["exit_code"]
433
+ task_complete = data["task_complete"] == true || data["task_complete"].to_s == "true"
434
+ exit_success = exit_code.nil? || exit_code.to_i == 0
435
+ state = exit_success ? "completed" : "failed"
436
+ done = task_complete || exit_success
437
+ payload = data.merge(
438
+ "ok" => done,
439
+ "id" => id,
440
+ "state" => state,
441
+ "process_state" => "exited",
442
+ "terminal" => true,
443
+ "task_complete" => task_complete,
444
+ "done" => done,
445
+ "work_state" => Harnex.work_state_for(state, task_complete: task_complete)
446
+ )
447
+ success = done
448
+ puts JSON.generate(payload)
449
+ return 0 if success
450
+
451
+ exit_code.is_a?(Integer) && exit_code.positive? ? exit_code : 1
452
+ rescue JSON::ParserError
453
+ puts JSON.generate(ok: false, id: id, state: "failed", process_state: "exited", terminal: true,
454
+ task_complete: false, done: false, work_state: "failed", status: "invalid_exit_status")
455
+ 1
456
+ end
457
+
458
+ def emit_done_terminal_status(status)
459
+ payload = terminal_payload(status)
460
+ payload[:ok] = !!payload[:done]
461
+ payload[:status] = payload[:done] ? "done" : status["state"]
462
+ puts JSON.generate(payload)
463
+
464
+ if payload[:ok]
465
+ 0
466
+ elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
467
+ status["exit_code"]
468
+ else
469
+ 1
470
+ end
471
+ end
472
+
331
473
  def emit_terminal_status(status)
332
- payload = {
333
- ok: status["state"] == "completed",
334
- id: status["id"],
335
- state: status["state"],
336
- terminal: true,
337
- task_complete: status["task_complete"],
338
- exit: status["exit"],
339
- exit_code: status["exit_code"],
340
- summary_out: status["summary_out"],
341
- ended_at: status["ended_at"],
342
- source: status["source"]
343
- }
474
+ payload = terminal_payload(status)
475
+ payload[:ok] = status["state"] == "completed"
344
476
  puts JSON.generate(payload)
345
477
 
346
478
  if payload[:ok]
@@ -352,6 +484,27 @@ module Harnex
352
484
  end
353
485
  end
354
486
 
487
+ def terminal_payload(status)
488
+ task_complete = !!status["task_complete"]
489
+ work_state = status["work_state"] || Harnex.work_state_for(status["state"], task_complete: task_complete)
490
+ done = status.key?("done") ? !!status["done"] : work_state == "completed"
491
+ {
492
+ ok: false,
493
+ id: status["id"],
494
+ state: status["state"],
495
+ process_state: status["process_state"] || Harnex.process_state_for(status["state"], terminal: true),
496
+ terminal: status.key?("terminal") ? !!status["terminal"] : true,
497
+ task_complete: task_complete,
498
+ done: done,
499
+ work_state: work_state,
500
+ exit: status["exit"],
501
+ exit_code: status["exit_code"],
502
+ summary_out: status["summary_out"],
503
+ ended_at: status["ended_at"],
504
+ source: status["source"]
505
+ }
506
+ end
507
+
355
508
  def parser
356
509
  @parser ||= OptionParser.new do |opts|
357
510
  opts.banner = "Usage: harnex wait [options]"
data/lib/harnex/core.rb CHANGED
@@ -68,6 +68,36 @@ module Harnex
68
68
  VERSION
69
69
  end
70
70
 
71
+ def work_state_for(session_state, task_complete: false)
72
+ return "completed" if task_complete
73
+
74
+ case session_state.to_s
75
+ when "completed"
76
+ "completed"
77
+ when "failed"
78
+ "failed"
79
+ when "running"
80
+ "running"
81
+ else
82
+ "unknown"
83
+ end
84
+ end
85
+
86
+ def work_done_for(session_state, task_complete: false)
87
+ work_state_for(session_state, task_complete: task_complete) == "completed"
88
+ end
89
+
90
+ def process_state_for(session_state, terminal: false)
91
+ return "running" unless terminal
92
+
93
+ case session_state.to_s
94
+ when "completed", "failed"
95
+ "exited"
96
+ else
97
+ "unknown"
98
+ end
99
+ end
100
+
71
101
  def host_info
72
102
  {
73
103
  host: Socket.gethostname,
@@ -221,9 +221,14 @@ module Harnex
221
221
  end
222
222
 
223
223
  payload[:input_state] = adapter.input_state(screen_snapshot) if include_input_state
224
+ task_complete = !!@last_completed_at
224
225
  payload[:agent_state] = @state_machine.to_s
226
+ payload[:process_state] = "running"
225
227
  payload[:inbox] = @inbox.stats
226
228
  payload[:last_completed_at] = @last_completed_at&.iso8601
229
+ payload[:task_complete] = task_complete
230
+ payload[:done] = Harnex.work_done_for("running", task_complete: task_complete)
231
+ payload[:work_state] = Harnex.work_state_for("running", task_complete: task_complete)
227
232
  payload[:model] = summary_model
228
233
  payload[:effort] = meta_hash["effort"]
229
234
  payload[:auto_disconnects] = @event_counters.snapshot[:disconnections]
@@ -733,6 +738,8 @@ module Harnex
733
738
  return unless defined?(@exit_code) && !@exit_code.nil?
734
739
 
735
740
  exit_path = Harnex.exit_status_path(repo_root, id)
741
+ task_complete = !!@last_completed_at
742
+ state = @exit_code.to_i == 0 ? "completed" : "failed"
736
743
  payload = {
737
744
  ok: true,
738
745
  id: id,
@@ -740,6 +747,11 @@ module Harnex
740
747
  session_id: session_id,
741
748
  repo_root: repo_root,
742
749
  exit_code: @exit_code,
750
+ state: state,
751
+ process_state: "exited",
752
+ task_complete: task_complete,
753
+ done: Harnex.work_done_for(state, task_complete: task_complete),
754
+ work_state: Harnex.work_state_for(state, task_complete: task_complete),
743
755
  started_at: @started_at.iso8601,
744
756
  exited_at: Time.now.iso8601,
745
757
  injected_count: @injected_count
@@ -42,8 +42,11 @@ module Harnex
42
42
  "id" => Harnex.normalize_id(id),
43
43
  "repo_root" => File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root),
44
44
  "state" => "unknown",
45
+ "process_state" => "unknown",
45
46
  "terminal" => false,
46
47
  "task_complete" => false,
48
+ "done" => false,
49
+ "work_state" => "unknown",
47
50
  "exit" => nil,
48
51
  "exit_code" => nil,
49
52
  "summary_out" => nil,
@@ -127,12 +130,17 @@ module Harnex
127
130
  meta = record["meta"] || {}
128
131
  actual = record["actual"] || {}
129
132
  state = classify_summary_state(actual)
133
+ task_complete = !!actual["task_complete"]
134
+ terminal = state != "unknown"
130
135
  {
131
136
  "id" => meta["id"].to_s,
132
137
  "repo_root" => meta["repo"] || fallback_repo_root,
133
138
  "state" => state,
134
- "terminal" => state != "unknown",
135
- "task_complete" => !!actual["task_complete"],
139
+ "process_state" => Harnex.process_state_for(state, terminal: terminal),
140
+ "terminal" => terminal,
141
+ "task_complete" => task_complete,
142
+ "done" => Harnex.work_done_for(state, task_complete: task_complete),
143
+ "work_state" => Harnex.work_state_for(state, task_complete: task_complete),
136
144
  "exit" => blank_to_nil(actual["exit"]),
137
145
  "exit_code" => actual["exit_code"],
138
146
  "summary_out" => summary_path,
@@ -164,12 +172,17 @@ module Harnex
164
172
  else
165
173
  "unknown"
166
174
  end
175
+ task_complete = record["terminal_event"].to_s == "task_complete"
176
+ terminal = state != "unknown"
167
177
  {
168
178
  "id" => record["id"].to_s,
169
179
  "repo_root" => fallback_repo_root,
170
180
  "state" => state,
171
- "terminal" => state != "unknown",
172
- "task_complete" => record["terminal_event"].to_s == "task_complete",
181
+ "process_state" => Harnex.process_state_for(state, terminal: terminal),
182
+ "terminal" => terminal,
183
+ "task_complete" => task_complete,
184
+ "done" => Harnex.work_done_for(state, task_complete: task_complete),
185
+ "work_state" => Harnex.work_state_for(state, task_complete: task_complete),
173
186
  "exit" => history_exit(status),
174
187
  "exit_code" => nil,
175
188
  "summary_out" => blank_to_nil(record["summary_out_path"]),
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.5"
3
- RELEASE_DATE = "2026-05-26"
2
+ VERSION = "0.7.6"
3
+ RELEASE_DATE = "2026-06-09"
4
4
  end
@@ -19,7 +19,8 @@ harnex run codex --id cx-23 --tmux
19
19
  If the plan file is self-contained, reference it directly:
20
20
 
21
21
  ```bash
22
- harnex send --id cx-23 --message "Implement koder/plans/plan_23.md. Run tests when done." --wait-for-idle --timeout 1200
22
+ harnex send --id cx-23 --message "Implement koder/plans/plan_23.md. Run tests when done."
23
+ harnex wait --id cx-23 --until done --timeout 1200
23
24
  ```
24
25
 
25
26
  For tasks that need structured output, tell the worker to write a
@@ -32,12 +33,13 @@ Run the full test suite when done.
32
33
  Write a short summary to /tmp/impl-23.md.
33
34
  EOF
34
35
 
35
- harnex send --id cx-23 --message "Read and execute /tmp/task-cx-23.md" --wait-for-idle --timeout 1200
36
+ harnex send --id cx-23 --message "Read and execute /tmp/task-cx-23.md"
37
+ harnex wait --id cx-23 --until done --timeout 1200
36
38
  ```
37
39
 
38
40
  ### 3. Watch until done
39
41
 
40
- Use `--wait-for-idle` as the fence, then read the worker's screen:
42
+ Use `harnex wait --until done` as the work-level fence, then read the worker's screen:
41
43
 
42
44
  ```bash
43
45
  harnex pane --id cx-23 --lines 25
data/recipes/03_buddy.md CHANGED
@@ -45,8 +45,9 @@ Your job:
45
45
  2. If the worker appears stuck at a prompt for more than 10 minutes
46
46
  with no progress, nudge it:
47
47
  - `harnex send --id worker-42 --message "You appear to have stalled. Continue with your current task."`
48
- 3. If the worker has exited (status shows no session), report back:
49
- - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "worker-42 has exited. Check results." Enter`
48
+ 3. If `harnex status --id worker-42 --json` reports `done=true` or
49
+ `work_state=failed`, report back:
50
+ - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "worker-42 reached a work-level terminal state. Check results." Enter`
50
51
  4. Keep watching until the worker finishes or is stopped.
51
52
 
52
53
  Do not interfere with work in progress. Only nudge when clearly stalled.
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.5
4
+ version: 0.7.6
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-05-26 00:00:00.000000000 Z
11
+ date: 2026-06-08 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.