harnex 0.7.4 → 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: e99b85fa8112b666665da16757d871098867e0b05c9521c3507fc2092616f825
4
- data.tar.gz: 78a5190335ee968c77d75531d23998c34272d24a3b6a8d6a53d5e9a8922c87da
3
+ metadata.gz: f29114f723ccfc61ade344c784cb6a11144c25e79392bf136f5b722473b47f0b
4
+ data.tar.gz: 810686487788509887bb8bfb5f9a4c45a8a1b8bca645530b1bf178f1435cee40
5
5
  SHA512:
6
- metadata.gz: 4c32673867f2b86ee84fa1768ee4fee82a3ddb0fb69abd5c369349d59fe9feef3cc468ee71776115da19ca2997b1f647678b413f8c93d02538f56c17daf316d4
7
- data.tar.gz: bf049097af8736c25991af1894dd15b6cc8159393f99eb215a0dc7ddd67f9716b1864589664fdd08671edc6859899c9cb84c46852b940323b8f11f42d5f8ee29
6
+ metadata.gz: 90c68832ab9218716fd2e6181f006a12b996a23c28b120a769411618aee35e507c38df43bcf9293a98fb3902923e70eb1be9e08a90795e9ec970cb5b51ca2b54
7
+ data.tar.gz: 64ebab38e3cf716bfe69fd7f8d1b28c0d114ef72e81f8028cb87f0b83f6a75cb555e1036a7831a41b3dddd156c0b9f71e51fa9550271f5be529dcea1fd23c9e1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
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
+
23
+ ## [0.7.5] - 2026-05-26 | 05:18 PM | IST
24
+
25
+ ### Added
26
+
27
+ - `Harnex::TerminalStatus` now resolves durable terminal dispatch state from
28
+ summary/history rows so commands can classify inactive sessions without
29
+ relying on tmp done markers.
30
+
31
+ ### Changed
32
+
33
+ - `harnex status --json --id <id>` now returns a machine-readable row even when
34
+ the live session is gone, with `state` in `running|completed|failed|unknown`
35
+ and terminal metadata (`terminal`, `exit`, `exit_code`, `summary_out`).
36
+ - `harnex wait --id <id>` now falls back to terminal summary/history telemetry
37
+ when registry and exit-status files are missing, returning `completed` on
38
+ summary success and `unknown` when no durable terminal signal exists.
39
+ - Monitoring guides now treat `/tmp/*-done.txt` as legacy compatibility hints;
40
+ canonical completion is `harnex wait` / `harnex status --json` / dispatch
41
+ summary rows.
42
+
5
43
  ## [0.7.4] - 2026-05-25 | 08:45 AM | IST
6
44
 
7
45
  ### 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 the worker exits or writes `/tmp/pi-i-42-done.txt`, report back:
58
- - `tmux send-keys -t "$HARNEX_SPAWNER_PANE" "pi-i-42 finished. Check /tmp/pi-i-42-done.txt." 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,23 +17,29 @@ 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, declare done with a conjunction of work-level facts:
28
+ For unattended work, first gate on harnex work completion, then verify the task
29
+ artifact and repo health:
28
30
 
29
31
  ```bash
30
- test -f /tmp/pi-i-NN-done.txt &&
31
- test -z "$(git status --short)" &&
32
- test "$(git log -1 --format=%ct)" -lt "$(($(date +%s) - 600))"
32
+ harnex wait --id pi-i-NN --until done --timeout 5400 &&
33
+ test -f path/to/expected-artifact &&
34
+ test -z "$(git status --short)"
33
35
  ```
34
36
 
35
- Adjust the artifact path and commit-age window to the task. The point is to
36
- avoid declaring done while a worker is between edits or between commits.
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.
40
+
41
+ Adjust the artifact path to the task. The point is to avoid declaring done while
42
+ a worker is between edits or between commits.
37
43
 
38
44
  ## Why Pane State Alone Is Not Enough
39
45
 
@@ -68,27 +74,38 @@ harnex events --id pi-i-NN
68
74
  For task completion:
69
75
 
70
76
  ```bash
77
+ harnex wait --id pi-i-NN --until done --timeout 900
78
+ # Or, when you specifically need the structured turn event:
71
79
  harnex wait --id pi-i-NN --until task_complete --timeout 900
72
80
  ```
73
81
 
74
82
  ## Background Sweeper
75
83
 
76
- Consumers often run a small shell loop that checks the expected done marker,
77
- tree state, and harnex liveness. Keep a hard wall-clock cap so an unattended
78
- pipeline cannot wait forever:
84
+ Consumers often run a small shell loop that checks terminal state, then drops
85
+ to pane diagnostics only while work is still running. Keep a hard wall-clock cap
86
+ so an unattended pipeline cannot wait forever:
79
87
 
80
88
  ```bash
81
89
  start=$(date +%s)
82
90
  max_wait=5400
83
91
 
84
- until test -f /tmp/pi-i-NN-done.txt; do
92
+ while :; do
85
93
  if test "$(($(date +%s) - start))" -gt "$max_wait"; then
86
94
  echo "wall-clock cap hit for pi-i-NN" >&2
87
95
  exit 2
88
96
  fi
89
97
 
90
- harnex status --id pi-i-NN --json
91
- harnex pane --id pi-i-NN --lines 20
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)')
101
+ state=$(printf '%s' "$row" | ruby -rjson -e 'print(JSON.parse(STDIN.read)["state"].to_s)')
102
+
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 ;;
106
+ *) harnex pane --id pi-i-NN --lines 20 ;;
107
+ esac
108
+
92
109
  sleep 60
93
110
  done
94
111
  ```
@@ -124,7 +141,9 @@ interpretation.
124
141
 
125
142
  ## Anti-Patterns
126
143
 
144
+ - Polling `state=completed` alone and missing live sessions with `task_complete=true`.
127
145
  - Polling `state=prompt` alone and calling it done.
146
+ - Blocking orchestrators on `/tmp/*-done.txt` as the only completion signal.
128
147
  - Letting an unattended loop run with no wall-clock cap.
129
148
  - Reading raw tmux panes instead of `harnex pane`.
130
149
  - Using `--wait-for-idle` as acceptance proof.
data/guides/05_naming.md CHANGED
@@ -93,9 +93,9 @@ The task file name does not need to duplicate the exact short phase code. It
93
93
  should be easy to scan in `/tmp` and should include the same task number as the
94
94
  session ID.
95
95
 
96
- ## Done Markers
96
+ ## Done Markers (Legacy Compatibility)
97
97
 
98
- Derive done markers from the session ID:
98
+ If a legacy workflow still expects a done marker, derive it from the session ID:
99
99
 
100
100
  ```text
101
101
  /tmp/pi-p-42-done.txt
@@ -103,5 +103,9 @@ Derive done markers from the session ID:
103
103
  /tmp/pi-cr-42-done.txt
104
104
  ```
105
105
 
106
+ Treat done markers as compatibility hints only. Canonical completion should come
107
+ from harnex terminal telemetry (`harnex wait` / `harnex status --json` / summary
108
+ rows in `.harnex/dispatch.jsonl`).
109
+
106
110
  When a brief asks for a completion marker, make it one line and include the
107
111
  highest-signal result: tests passed, review clean, or the blocking issue.
@@ -28,6 +28,10 @@ module Harnex
28
28
  Gotchas:
29
29
  By default, status filters to the current repo root.
30
30
  Use --all when supervising workers launched from sibling worktrees.
31
+ With --id, terminal summaries can report completed/failed/unknown
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.
31
35
  A prompt-like state is not a completion signal by itself.
32
36
  TEXT
33
37
  end
@@ -83,12 +87,40 @@ module Harnex
83
87
  end
84
88
 
85
89
  def load_sessions
86
- repo_root = @options[:all] ? nil : Harnex.resolve_repo_root(@options[:repo_path])
87
- sessions = Harnex.active_sessions(repo_root, id: @options[:id])
90
+ active_repo_root = @options[:all] ? nil : Harnex.resolve_repo_root(@options[:repo_path])
91
+ fallback_repo_root = Harnex.resolve_repo_root(@options[:repo_path])
92
+ sessions = Harnex.active_sessions(active_repo_root, id: @options[:id])
93
+
94
+ live = sessions.map { |session| normalize_live_status(load_live_status(session)) }
95
+ .sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }
96
+ .reverse
97
+ return live unless @options[:id]
98
+ return [live.first] unless live.empty?
99
+
100
+ terminal = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: fallback_repo_root)
101
+ [terminal || Harnex::TerminalStatus.unknown(id: @options[:id], repo_root: fallback_repo_root)]
102
+ end
103
+
104
+ def normalize_live_status(session)
105
+ task_complete = task_complete?(session)
106
+ session.merge(
107
+ "state" => "running",
108
+ "process_state" => "running",
109
+ "terminal" => false,
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),
113
+ "exit" => nil,
114
+ "exit_code" => nil,
115
+ "summary_out" => nil,
116
+ "ended_at" => nil,
117
+ "source" => "live"
118
+ )
119
+ end
88
120
 
89
- sessions.map { |session| load_live_status(session) }
90
- .sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }
91
- .reverse
121
+ def task_complete?(session)
122
+ session["task_complete"] == true || session["task_complete"].to_s == "true" ||
123
+ !session["last_completed_at"].to_s.empty?
92
124
  end
93
125
 
94
126
  def load_live_status(session)
@@ -128,7 +160,7 @@ module Harnex
128
160
  "PORT" => session["port"].to_s,
129
161
  "AGE" => timeago(session["started_at"]),
130
162
  "IDLE" => format_idle(session["log_idle_s"]),
131
- "STATE" => session.dig("input_state", "state").to_s.empty? ? "-" : session.dig("input_state", "state").to_s,
163
+ "STATE" => table_state(session),
132
164
  "DESC" => truncate(session["description"])
133
165
  }
134
166
  row["REPO"] = truncate_repo(session["repo_root"])
@@ -139,6 +171,14 @@ module Harnex
139
171
  columns.map { |column| row.fetch(column).ljust(widths.fetch(column)) }.join(" ")
140
172
  end
141
173
 
174
+ def table_state(session)
175
+ input_state = session.dig("input_state", "state").to_s
176
+ return input_state unless input_state.empty?
177
+
178
+ state = session["state"].to_s
179
+ state.empty? ? "-" : state
180
+ end
181
+
142
182
  def timeago(timestamp)
143
183
  return "-" if timestamp.to_s.empty?
144
184
 
@@ -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,13 +33,17 @@ 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.
45
+ Exit waits can resolve from terminal summary rows when live registry/
46
+ exit-status files are already gone.
41
47
  Without --timeout, wait can block indefinitely.
42
48
  TEXT
43
49
  end
@@ -63,7 +69,10 @@ module Harnex
63
69
  raise "--id is required for harnex wait" unless @options[:id]
64
70
 
65
71
  if @options[:until_state]
66
- if EVENT_PREDICATES.include?(@options[:until_state])
72
+ case @options[:until_state]
73
+ when "done"
74
+ wait_until_done
75
+ when *EVENT_PREDICATES
67
76
  wait_until_event(@options[:until_state])
68
77
  else
69
78
  wait_until_state
@@ -133,7 +142,7 @@ module Harnex
133
142
 
134
143
  task_complete_seen = true if event_type(event) == "task_complete"
135
144
  if matches?(event, predicate, task_complete_seen)
136
- 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]
137
146
  end
138
147
  end
139
148
  offset = f.pos
@@ -164,7 +173,7 @@ module Harnex
164
173
  def matches?(event, predicate, task_complete_seen)
165
174
  type = event_type(event)
166
175
  case predicate
167
- when "task_complete"
176
+ when "task_complete", "done"
168
177
  type == "task_complete"
169
178
  when "prompt"
170
179
  type == "task_complete" ||
@@ -174,18 +183,100 @@ module Harnex
174
183
  end
175
184
  end
176
185
 
177
- def emit_event_match(event, start_time)
186
+ def emit_event_match(event, start_time, predicate)
178
187
  waited = (Time.now - start_time).round(1)
179
- puts JSON.generate(
188
+ payload = {
180
189
  ok: true,
181
190
  id: @options[:id],
182
191
  event: event_type(event),
183
192
  seq: event["seq"],
184
193
  waited_seconds: waited
185
- )
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)
186
207
  0
187
208
  end
188
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
+
189
280
  def wait_until_state
190
281
  repo_root = Harnex.resolve_repo_root(@options[:repo_path])
191
282
  target_state = @options[:until_state]
@@ -238,7 +329,12 @@ module Harnex
238
329
  unless registry
239
330
  return read_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
240
331
 
332
+ terminal = terminal_status(repo_root)
333
+ return emit_terminal_status(terminal) if terminal
334
+
241
335
  warn("harnex wait: no session found with id #{@options[:id].inspect}")
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")
242
338
  return 1
243
339
  end
244
340
 
@@ -248,7 +344,14 @@ module Harnex
248
344
  loop do
249
345
  unless Harnex.alive_pid?(target_pid)
250
346
  await_exit_status(exit_path)
251
- return read_exit_status(exit_path, @options[:id])
347
+ return read_exit_status(exit_path, @options[:id]) if File.exist?(exit_path)
348
+
349
+ terminal = terminal_status(repo_root)
350
+ return emit_terminal_status(terminal) if terminal
351
+
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")
354
+ return 1
252
355
  end
253
356
 
254
357
  if deadline && Time.now >= deadline
@@ -308,6 +411,100 @@ module Harnex
308
411
  end
309
412
  end
310
413
 
414
+ def terminal_status(repo_root)
415
+ status = Harnex::TerminalStatus.resolve(id: @options[:id], repo_root: repo_root)
416
+ return nil unless status
417
+ return nil unless status["terminal"]
418
+
419
+ status
420
+ end
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
+
473
+ def emit_terminal_status(status)
474
+ payload = terminal_payload(status)
475
+ payload[:ok] = status["state"] == "completed"
476
+ puts JSON.generate(payload)
477
+
478
+ if payload[:ok]
479
+ 0
480
+ elsif status["exit_code"].is_a?(Integer) && status["exit_code"] > 0
481
+ status["exit_code"]
482
+ else
483
+ 1
484
+ end
485
+ end
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
+
311
508
  def parser
312
509
  @parser ||= OptionParser.new do |opts|
313
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
@@ -0,0 +1,215 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Harnex
5
+ module TerminalStatus
6
+ module_function
7
+
8
+ def resolve(id:, repo_root: Dir.pwd)
9
+ normalized_id = Harnex.normalize_id(id)
10
+ root = File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root)
11
+
12
+ latest_summary = nil
13
+ latest_summary_path = nil
14
+ latest_history = nil
15
+
16
+ history_paths(root).each do |path|
17
+ summary, history = scan_dispatch_path(path, normalized_id)
18
+ if newer_summary?(summary, latest_summary)
19
+ latest_summary = summary
20
+ latest_summary_path = path
21
+ end
22
+ latest_history = history if newer_history?(history, latest_history)
23
+ end
24
+
25
+ summary_path = latest_history && latest_history["summary_out_path"].to_s.strip
26
+ if summary_path && !summary_path.empty? && File.file?(summary_path)
27
+ summary, = scan_dispatch_path(summary_path, normalized_id)
28
+ if newer_summary?(summary, latest_summary)
29
+ latest_summary = summary
30
+ latest_summary_path = summary_path
31
+ end
32
+ end
33
+
34
+ return build_from_summary(latest_summary, latest_summary_path, root) if latest_summary
35
+ return build_from_history(latest_history, root) if latest_history
36
+
37
+ nil
38
+ end
39
+
40
+ def unknown(id:, repo_root: Dir.pwd)
41
+ {
42
+ "id" => Harnex.normalize_id(id),
43
+ "repo_root" => File.expand_path(repo_root.to_s.empty? ? Dir.pwd : repo_root),
44
+ "state" => "unknown",
45
+ "process_state" => "unknown",
46
+ "terminal" => false,
47
+ "task_complete" => false,
48
+ "done" => false,
49
+ "work_state" => "unknown",
50
+ "exit" => nil,
51
+ "exit_code" => nil,
52
+ "summary_out" => nil,
53
+ "started_at" => nil,
54
+ "ended_at" => nil,
55
+ "source" => "none"
56
+ }
57
+ end
58
+
59
+ def history_paths(repo_root)
60
+ local_path = DispatchHistory.path_for(repo_root)
61
+ return [local_path] if File.file?(local_path)
62
+
63
+ global_path = DispatchHistory.global_path
64
+ return [global_path] if File.file?(global_path)
65
+
66
+ []
67
+ rescue StandardError
68
+ []
69
+ end
70
+
71
+ def scan_dispatch_path(path, id)
72
+ summary_record = nil
73
+ history_record = nil
74
+
75
+ File.foreach(path) do |line|
76
+ record = JSON.parse(line)
77
+ next unless record.is_a?(Hash)
78
+
79
+ if summary_record?(record) && record.dig("meta", "id").to_s == id
80
+ summary_record = record
81
+ elsif history_record?(record) && record["id"].to_s == id
82
+ history_record = record
83
+ end
84
+ rescue JSON::ParserError
85
+ next
86
+ end
87
+
88
+ [summary_record, history_record]
89
+ rescue Errno::ENOENT
90
+ [nil, nil]
91
+ end
92
+
93
+ def summary_record?(record)
94
+ record["meta"].is_a?(Hash) && record["actual"].is_a?(Hash)
95
+ end
96
+
97
+ def history_record?(record)
98
+ record["schema_version"] == 1 && record.key?("status")
99
+ end
100
+
101
+ def newer_summary?(candidate, current)
102
+ return false unless candidate
103
+ return true unless current
104
+
105
+ summary_time(candidate) >= summary_time(current)
106
+ end
107
+
108
+ def newer_history?(candidate, current)
109
+ return false unless candidate
110
+ return true unless current
111
+
112
+ history_time(candidate) >= history_time(current)
113
+ end
114
+
115
+ def summary_time(record)
116
+ parse_time(record.dig("meta", "ended_at")) || parse_time(record.dig("meta", "started_at")) || Time.at(0)
117
+ end
118
+
119
+ def history_time(record)
120
+ parse_time(record["ended_at"]) || parse_time(record["started_at"]) || Time.at(0)
121
+ end
122
+
123
+ def parse_time(value)
124
+ Time.iso8601(value.to_s)
125
+ rescue ArgumentError
126
+ nil
127
+ end
128
+
129
+ def build_from_summary(record, summary_path, fallback_repo_root)
130
+ meta = record["meta"] || {}
131
+ actual = record["actual"] || {}
132
+ state = classify_summary_state(actual)
133
+ task_complete = !!actual["task_complete"]
134
+ terminal = state != "unknown"
135
+ {
136
+ "id" => meta["id"].to_s,
137
+ "repo_root" => meta["repo"] || fallback_repo_root,
138
+ "state" => state,
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),
144
+ "exit" => blank_to_nil(actual["exit"]),
145
+ "exit_code" => actual["exit_code"],
146
+ "summary_out" => summary_path,
147
+ "started_at" => meta["started_at"],
148
+ "ended_at" => meta["ended_at"],
149
+ "source" => "summary_out"
150
+ }
151
+ end
152
+
153
+ def classify_summary_state(actual)
154
+ exit = actual["exit"].to_s
155
+ exit_code = actual["exit_code"]
156
+
157
+ return "completed" if exit == "success"
158
+ return "completed" if exit.empty? && exit_code == 0
159
+ return "failed" unless exit.empty? && exit_code.nil?
160
+
161
+ "unknown"
162
+ end
163
+
164
+ def build_from_history(record, fallback_repo_root)
165
+ status = record["status"].to_s
166
+ state =
167
+ case status
168
+ when "completed"
169
+ "completed"
170
+ when "failed", "timeout", "killed"
171
+ "failed"
172
+ else
173
+ "unknown"
174
+ end
175
+ task_complete = record["terminal_event"].to_s == "task_complete"
176
+ terminal = state != "unknown"
177
+ {
178
+ "id" => record["id"].to_s,
179
+ "repo_root" => fallback_repo_root,
180
+ "state" => state,
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),
186
+ "exit" => history_exit(status),
187
+ "exit_code" => nil,
188
+ "summary_out" => blank_to_nil(record["summary_out_path"]),
189
+ "started_at" => record["started_at"],
190
+ "ended_at" => record["ended_at"],
191
+ "source" => "dispatch_history"
192
+ }
193
+ end
194
+
195
+ def history_exit(status)
196
+ case status
197
+ when "completed"
198
+ "success"
199
+ when "timeout"
200
+ "timeout"
201
+ when "killed"
202
+ "killed"
203
+ when "failed"
204
+ "failure"
205
+ else
206
+ nil
207
+ end
208
+ end
209
+
210
+ def blank_to_nil(value)
211
+ text = value.to_s
212
+ text.empty? ? nil : text
213
+ end
214
+ end
215
+ end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.7.4"
3
- RELEASE_DATE = "2026-05-25"
2
+ VERSION = "0.7.6"
3
+ RELEASE_DATE = "2026-06-09"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -5,6 +5,7 @@ require "open3"
5
5
  require_relative "harnex/version"
6
6
  require_relative "harnex/core"
7
7
  require_relative "harnex/dispatch_history"
8
+ require_relative "harnex/terminal_status"
8
9
  require_relative "harnex/watcher"
9
10
  require_relative "harnex/adapters"
10
11
  require_relative "harnex/runtime/session_state"
@@ -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.4
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-25 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.
@@ -65,6 +65,7 @@ files:
65
65
  - lib/harnex/runtime/message.rb
66
66
  - lib/harnex/runtime/session.rb
67
67
  - lib/harnex/runtime/session_state.rb
68
+ - lib/harnex/terminal_status.rb
68
69
  - lib/harnex/version.rb
69
70
  - lib/harnex/watcher.rb
70
71
  - lib/harnex/watcher/inotify.rb