harnex 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5db22a80eebc5f0441437c96a2f949f0dd117cf78a2cc0810f24172c7af7cd4
4
- data.tar.gz: 3ad9ed119b373258b1e8c56ea80b27ea49dad028031e19bd4ee0697996a45046
3
+ metadata.gz: 2b38fbca70a1ec608f414f362bb16bff85bdb5c279a5c562c4dc7048fa6acb41
4
+ data.tar.gz: 7a9048de3efb9461489ab0678683e0d245e60e2ac3a1dd7e617d868fce927a51
5
5
  SHA512:
6
- metadata.gz: 1dd3ad5ef1cf5bb17d6a92ea6b27b746ef47a8e1a8e58b1e2a98936946f63f58d8d297d7493ffc3ab3968e25ac26415ae73c44524423a83f36c5951b4e729813
7
- data.tar.gz: 39cf0ed49b278a10ff57767d6cc166232e7c30ca9eed31853d079a2a437dcd0ee4709a8a13959e91bd106d8ce7b1e1b043b64a6df0fd24988a925266c488769a
6
+ metadata.gz: a222bd5df7a1e02e7b6cebb2e0a63649b4433baef17be35a3dd03b4128e9b406af52ae71e9c063a6912dbb9af04b187072a693271fb72f24e4d61de462a7bf1c
7
+ data.tar.gz: eb2ca6564d079b0e162e72e9d07d01239ba2ce0c5b118fbd4360671d555c452571b2c64cb0f5a4be3c0b23c13e57b606d7ca98632d345f7570d5c69caacc9ad8
data/GUIDE.md CHANGED
@@ -49,6 +49,17 @@ automatically when the agent is ready. You don't have to wait
49
49
  or retry. Queueing exists, but the default workflow should still be
50
50
  one task per fresh worker.
51
51
 
52
+ For unattended dispatch, prefer built-in monitoring over external poll loops:
53
+
54
+ ```bash
55
+ harnex run codex --id impl --tmux impl --watch --preset impl
56
+ ```
57
+
58
+ This adds a foreground watcher that checks idle activity and performs bounded
59
+ force-resume nudges. For full flag behavior and event-stream consumers, see
60
+ [TECHNICAL.md](TECHNICAL.md) and the built-in monitoring section in
61
+ [README.md](README.md).
62
+
52
63
  ## Seeing what's running
53
64
 
54
65
  ```bash
data/README.md CHANGED
@@ -105,12 +105,43 @@ Install skills so agents can use them:
105
105
  harnex skills install
106
106
  ```
107
107
 
108
+ ## Built-in dispatch monitoring
109
+
110
+ For unattended dispatches, use `--watch` instead of writing a bash poll loop:
111
+
112
+ ```bash
113
+ harnex run codex --id cx-impl-42 --tmux cx-impl-42 --watch --preset impl \
114
+ --context "Implement koder/plans/42_plan.md. Run tests and commit when done."
115
+ ```
116
+
117
+ `--watch` runs a foreground babysitter that checks session activity every 60s,
118
+ force-resumes on stall up to a cap, and exits when the target session exits or
119
+ the resume cap is reached.
120
+
121
+ Presets map to stall policy defaults:
122
+
123
+ - `impl` -> `--stall-after 8m --max-resumes 1`
124
+ - `plan` -> `--stall-after 3m --max-resumes 2`
125
+ - `gate` -> `--stall-after 15m --max-resumes 0`
126
+
127
+ Explicit `--stall-after` and `--max-resumes` flags override preset defaults.
128
+
129
+ For structured subscriptions, stream JSONL events:
130
+
131
+ ```bash
132
+ harnex events --id cx-impl-42 | jq -c '.'
133
+ ```
134
+
135
+ Schema details and compatibility policy are documented in
136
+ [docs/events.md](docs/events.md).
137
+
108
138
  ## Long-running and overnight work
109
139
 
110
- A **buddy** is a second agent that watches something and acts on it.
111
- It's just another harnex session no special monitoring code, no
112
- configuration. The buddy is an LLM, so it reasons about what it sees
113
- rather than pattern-matching.
140
+ For plain "force-resume on stall" recovery, use
141
+ `harnex run --watch --preset impl`.
142
+
143
+ A **buddy** is for richer reasoning: doc drift checks, semantic sanity checks,
144
+ and multi-session correlation. It's still just another harnex session.
114
145
 
115
146
  ### Example: keep a worker from stalling
116
147
 
@@ -168,12 +199,13 @@ See [recipes/03_buddy.md](recipes/03_buddy.md) for the full pattern.
168
199
 
169
200
  | Command | What it does |
170
201
  |---------|-------------|
171
- | `harnex run <cli>` | Start an agent (`--tmux` for a visible window, `--detach` for background) |
202
+ | `harnex run <cli>` | Start an agent (`--tmux` visible, `--detach` background, `--watch` built-in monitoring) |
172
203
  | `harnex send --id <id>` | Send a message (queues if busy, `--wait-for-idle` to block until done) |
173
204
  | `harnex stop --id <id>` | Send the agent's native exit sequence |
174
205
  | `harnex status` | List running sessions (`--json` for full payloads) |
175
206
  | `harnex pane --id <id>` | Capture the agent's tmux screen (`--follow` for live) |
176
207
  | `harnex logs --id <id>` | Read session transcript (`--follow` to tail) |
208
+ | `harnex events --id <id>` | Stream structured session events (`--snapshot` for non-blocking dump) |
177
209
  | `harnex wait --id <id>` | Block until exit or a target state |
178
210
  | `harnex guide` | Getting started walkthrough |
179
211
  | `harnex recipes` | Tested workflow patterns |
data/TECHNICAL.md CHANGED
@@ -14,17 +14,21 @@ harnex run claude --id review
14
14
  harnex run codex -- --cd ~/other/repo
15
15
  ```
16
16
 
17
- | Flag | What it does |
18
- |-----------------|---------------------------------------|
19
- | `--id ID` | Name this session (default: random) |
20
- | `--description` | Store a short session description |
21
- | `--detach` | Run in background, no terminal |
22
- | `--tmux [NAME]` | Run in a tmux window you can watch |
23
- | `--host HOST` | Bind a specific API host |
24
- | `--port PORT` | Force a specific API port |
25
- | `--context TXT` | Give the agent a task on startup |
26
- | `--watch PATH` | Notify agent when a file changes |
27
- | `--timeout SEC` | Wait budget for detached registration |
17
+ | Flag | What it does |
18
+ |---------------------|---------------------------------------------------------------------|
19
+ | `--id ID` | Name this session (default: random) |
20
+ | `--description` | Store a short session description |
21
+ | `--detach` | Run in background, no terminal |
22
+ | `--tmux [NAME]` | Run in a tmux window you can watch |
23
+ | `--host HOST` | Bind a specific API host |
24
+ | `--port PORT` | Force a specific API port |
25
+ | `--watch` | Enable blocking babysitter mode (foreground only) |
26
+ | `--stall-after DUR` | Idle threshold before force-resume (default: `480s`) |
27
+ | `--max-resumes N` | Max forced resumes before escalation (default: `1`) |
28
+ | `--preset NAME` | Watch preset (`impl`, `plan`, `gate`), requires `--watch` |
29
+ | `--watch-file PATH` | Auto-send a file-change hook (`--watch PATH`/`--watch=PATH` legacy) |
30
+ | `--context TXT` | Give the agent a task on startup |
31
+ | `--timeout SEC` | Wait budget for detached registration |
28
32
 
29
33
  ### `harnex send` — Talk to a running agent
30
34
 
@@ -61,12 +65,20 @@ failures for up to the given timeout.
61
65
  ### `harnex status` — See running agents
62
66
 
63
67
  ```
64
- ID CLI AGE STATE
65
- worker codex 36s ago prompt
66
- review claude 8s ago busy
68
+ ID CLI PID PORT AGE IDLE STATE REPO DESC
69
+ worker codex 12345 43123 36s 12s prompt ~/... -
70
+ review claude 12346 43124 8s - busy ~/... -
67
71
  ```
68
72
 
69
- Use `--json` for full payloads. Use `--all` for all repos.
73
+ Text mode includes an `IDLE` column derived from `log_idle_s` (`-` means no
74
+ transcript activity yet).
75
+
76
+ Use `--json` for full payloads. JSON includes:
77
+
78
+ - `log_mtime` (ISO8601 or `null`) — transcript file mtime
79
+ - `log_idle_s` (Integer or `null`) — seconds since last transcript write
80
+
81
+ Use `--all` for all repos.
70
82
 
71
83
  ### `harnex wait` — Wait for an agent to finish
72
84
 
@@ -160,12 +172,54 @@ harnex run codex --id impl-1 --tmux cx-p1 \
160
172
  ### File watching
161
173
 
162
174
  ```bash
163
- harnex run codex --id worker --watch ./tmp/status.jsonl
175
+ harnex run codex --id worker --watch-file ./tmp/status.jsonl
164
176
  ```
165
177
 
166
178
  Agent gets notified when the file changes. File doesn't need to
167
179
  exist at startup.
168
180
 
181
+ Legacy compatibility: `--watch PATH` and `--watch=PATH` still configure
182
+ file-hook mode.
183
+
184
+ ## harnex events
185
+
186
+ `harnex events` streams structured per-session JSONL for orchestrators and
187
+ monitoring tooling.
188
+
189
+ ```bash
190
+ harnex events --id worker
191
+ harnex events --id worker --snapshot
192
+ harnex events --id worker --from 2026-04-29T10:00:00Z
193
+ harnex events --id worker | jq -c '.'
194
+ ```
195
+
196
+ | Flag | What it does |
197
+ |------|---------------|
198
+ | `--id ID` | Session ID to inspect (required) |
199
+ | `--[no-]follow` | Stream appended events (default: follow) |
200
+ | `--snapshot` | Print current event file and exit (`--no-follow`) |
201
+ | `--from TS` | Replay floor (ISO-8601, inclusive; `ts >= from`) |
202
+ | `--repo PATH` | Resolve ID from a specific repo root |
203
+ | `--cli CLI` | Filter active-session resolution by CLI |
204
+
205
+ Exit codes:
206
+
207
+ - `0` — snapshot completed, or follow mode observed `type: "exited"`
208
+ - `1` — operational error (missing stream/session, invalid `--from`,
209
+ stream truncated/disappeared, lookup failure)
210
+
211
+ Transport file (append-only JSONL):
212
+
213
+ ```
214
+ ~/.local/state/harnex/events/<repo_hash>--<id>.jsonl
215
+ ```
216
+
217
+ Each row uses schema v1 with envelope fields `schema_version`, `seq`, `ts`,
218
+ `id`, and `type`. Emitted today: `started`, `send`, `exited`. `send.msg` is a
219
+ 200-character preview with `msg_truncated` when shortened.
220
+
221
+ Schema details and compatibility guarantees are in [docs/events.md](docs/events.md).
222
+
169
223
  ## Architecture
170
224
 
171
225
  ```
@@ -218,6 +272,8 @@ When you run `harnex run codex --id worker`:
218
272
  ~/.local/state/harnex/sessions/<repo_hash>--<id>.json
219
273
  and open transcript file:
220
274
  ~/.local/state/harnex/output/<repo_hash>--<id>.log
275
+ and open events file:
276
+ ~/.local/state/harnex/events/<repo_hash>--<id>.jsonl
221
277
  8. Start background threads:
222
278
  - PTY reader (screen buffer)
223
279
  - State machine (adapter parses screen for state)
@@ -39,6 +39,10 @@ module Harnex
39
39
  }
40
40
  end
41
41
 
42
+ def parse_session_summary(_transcript_tail)
43
+ {}
44
+ end
45
+
42
46
  def send_wait_seconds(submit:, enter_only:)
43
47
  0.0
44
48
  end
@@ -56,6 +56,25 @@ module Harnex
56
56
  end
57
57
  end
58
58
 
59
+ def parse_session_summary(transcript_tail)
60
+ summary = empty_session_summary
61
+ text = transcript_tail.to_s
62
+
63
+ if (match = text.match(/Token usage:\s+total=([\d,]+)\s+input=([\d,]+)(?:\s+\(\+\s+([\d,]+)\s+cached\))?\s+output=([\d,]+)(?:\s+\(reasoning\s+([\d,]+)\))?/))
64
+ summary[:total_tokens] = parse_token_count(match[1])
65
+ summary[:input_tokens] = parse_token_count(match[2])
66
+ summary[:cached_tokens] = parse_token_count(match[3])
67
+ summary[:output_tokens] = parse_token_count(match[4])
68
+ summary[:reasoning_tokens] = parse_token_count(match[5])
69
+ end
70
+
71
+ if (match = text.match(/codex resume\s+([0-9a-f-]{36})/))
72
+ summary[:agent_session_id] = match[1]
73
+ end
74
+
75
+ summary
76
+ end
77
+
59
78
  def send_wait_seconds(submit:, enter_only:)
60
79
  return 0.0 unless submit
61
80
  return 0.0 if enter_only
@@ -101,6 +120,23 @@ module Harnex
101
120
 
102
121
  protected
103
122
 
123
+ def empty_session_summary
124
+ {
125
+ input_tokens: nil,
126
+ output_tokens: nil,
127
+ reasoning_tokens: nil,
128
+ cached_tokens: nil,
129
+ total_tokens: nil,
130
+ agent_session_id: nil
131
+ }
132
+ end
133
+
134
+ def parse_token_count(value)
135
+ return nil if value.nil?
136
+
137
+ Integer(value.delete(","))
138
+ end
139
+
104
140
  def submit_delay_ms(text)
105
141
  extra = (text.to_s.bytesize / 1024.0 * SUBMIT_DELAY_PER_KB_MS).ceil
106
142
  SUBMIT_DELAY_MS + extra
data/lib/harnex/cli.rb CHANGED
@@ -21,6 +21,8 @@ module Harnex
21
21
  Status.new(@argv.drop(1)).run
22
22
  when "logs"
23
23
  Logs.new(@argv.drop(1)).run
24
+ when "events"
25
+ Events.new(@argv.drop(1)).run
24
26
  when "pane"
25
27
  Pane.new(@argv.drop(1)).run
26
28
  when "recipes"
@@ -59,6 +61,8 @@ module Harnex
59
61
  Status.usage
60
62
  when "logs"
61
63
  Logs.usage
64
+ when "events"
65
+ Events.usage
62
66
  when "pane"
63
67
  Pane.usage
64
68
  when "recipes"
@@ -81,6 +85,7 @@ module Harnex
81
85
  harnex stop --id ID [options]
82
86
  harnex status [options]
83
87
  harnex logs --id ID [options]
88
+ harnex events --id ID [options]
84
89
  harnex pane --id ID [options]
85
90
  harnex help [command]
86
91
 
@@ -91,6 +96,7 @@ module Harnex
91
96
  stop Send the adapter stop sequence to a session
92
97
  status List live sessions
93
98
  logs Read session output transcripts
99
+ events Stream per-session JSONL runtime events
94
100
  pane Capture the current tmux pane for a live session
95
101
  recipes List and read workflow recipes
96
102
  guide Show the getting started guide
@@ -109,6 +115,7 @@ module Harnex
109
115
  harnex run codex -- --cd /path/to/repo
110
116
  harnex status
111
117
  harnex logs --id main --follow
118
+ harnex events --id main --snapshot
112
119
  harnex pane --id main --lines 40
113
120
  harnex send --id main --message "Summarize current progress."
114
121
  harnex skills install
@@ -0,0 +1,212 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "time"
4
+
5
+ module Harnex
6
+ class Events
7
+ POLL_INTERVAL = 0.1
8
+
9
+ def self.usage(program_name = "harnex events")
10
+ <<~TEXT
11
+ Usage: #{program_name} [options]
12
+
13
+ Options:
14
+ --id ID Session ID to inspect (required)
15
+ --repo PATH Resolve using PATH's repo root (default: current repo)
16
+ --cli CLI Filter the active session by CLI
17
+ --[no-]follow Keep streaming appended events (default: true)
18
+ --snapshot Print current events and exit (alias for --no-follow)
19
+ --from TS Replay floor (ISO-8601, inclusive)
20
+ -h, --help Show this help
21
+ TEXT
22
+ end
23
+
24
+ def initialize(argv)
25
+ @argv = argv.dup
26
+ @options = {
27
+ id: nil,
28
+ repo_path: Dir.pwd,
29
+ cli: nil,
30
+ follow: true,
31
+ from: nil,
32
+ help: false
33
+ }
34
+ @tail_buffer = +""
35
+ end
36
+
37
+ def run
38
+ parser.parse!(@argv)
39
+ if @options[:help]
40
+ puts self.class.usage
41
+ return 0
42
+ end
43
+
44
+ raise "--id is required for harnex events" unless @options[:id]
45
+
46
+ target = resolve_target
47
+ return 1 unless target
48
+
49
+ offset = print_snapshot(target.fetch(:path))
50
+ return 0 unless @options[:follow] && target[:live]
51
+
52
+ follow(target.fetch(:path), offset)
53
+ end
54
+
55
+ private
56
+
57
+ def parser
58
+ @parser ||= OptionParser.new do |opts|
59
+ opts.banner = "Usage: harnex events [options]"
60
+ opts.on("--id ID", "Session ID to inspect") { |value| @options[:id] = Harnex.normalize_id(value) }
61
+ opts.on("--repo PATH", "Resolve using PATH's repo root") { |value| @options[:repo_path] = value }
62
+ opts.on("--cli CLI", "Filter the active session by CLI") { |value| @options[:cli] = value }
63
+ opts.on("--[no-]follow", "Keep streaming appended events") { |value| @options[:follow] = value }
64
+ opts.on("--snapshot", "Print current events and exit") { @options[:follow] = false }
65
+ opts.on("--from TS", "Replay floor (ISO-8601, inclusive)") { |value| @options[:from] = parse_from(value) }
66
+ opts.on("-h", "--help", "Show help") { @options[:help] = true }
67
+ end
68
+ end
69
+
70
+ def parse_from(value)
71
+ Time.iso8601(value.to_s)
72
+ rescue ArgumentError
73
+ raise OptionParser::InvalidArgument, "--from must be an ISO-8601 timestamp"
74
+ end
75
+
76
+ def resolve_target
77
+ repo_root = Harnex.resolve_repo_root(@options[:repo_path])
78
+ session = Harnex.read_registry(repo_root, @options[:id], cli: @options[:cli])
79
+
80
+ if session
81
+ path = session["events_log_path"].to_s
82
+ path = Harnex.events_log_path(repo_root, @options[:id]) if path.empty?
83
+ return event_target(path, live: true, repo_root: repo_root)
84
+ end
85
+
86
+ if cli_filter_mismatch?(repo_root)
87
+ warn("harnex events: no active session found with id #{@options[:id].inspect} and cli #{@options[:cli].inspect}")
88
+ return nil
89
+ end
90
+
91
+ event_target(Harnex.events_log_path(repo_root, @options[:id]), live: false, repo_root: repo_root)
92
+ end
93
+
94
+ def cli_filter_mismatch?(repo_root)
95
+ return false unless @options[:cli]
96
+
97
+ session = Harnex.read_registry(repo_root, @options[:id])
98
+ return false unless session
99
+
100
+ Harnex.cli_key(Harnex.session_cli(session)) != Harnex.cli_key(@options[:cli])
101
+ end
102
+
103
+ def event_target(path, live:, repo_root:)
104
+ return { path: path, live: live, repo_root: repo_root } if File.file?(path)
105
+
106
+ if live
107
+ warn("harnex events: stream not found at #{path}")
108
+ else
109
+ warn("harnex events: no session or event stream found with id #{@options[:id].inspect} for #{repo_root}")
110
+ end
111
+ nil
112
+ end
113
+
114
+ def print_snapshot(path)
115
+ offset = 0
116
+ File.open(path, "rb") do |file|
117
+ file.each_line do |line|
118
+ next unless emit_line?(line)
119
+
120
+ $stdout.write(line)
121
+ end
122
+ offset = file.pos
123
+ end
124
+ $stdout.flush
125
+ offset
126
+ end
127
+
128
+ def follow(path, offset)
129
+ current_offset = offset
130
+
131
+ loop do
132
+ streamed = stream_growth(path, current_offset)
133
+ return streamed.fetch(:code) if streamed[:code]
134
+
135
+ current_offset = streamed.fetch(:offset)
136
+ sleep POLL_INTERVAL
137
+ end
138
+ end
139
+
140
+ def stream_growth(path, offset)
141
+ unless File.file?(path)
142
+ warn("harnex events: stream source disappeared at #{path}")
143
+ return { code: 1, offset: offset }
144
+ end
145
+
146
+ size = File.size(path)
147
+ if size < offset
148
+ warn("harnex events: stream source was truncated at #{path}")
149
+ return { code: 1, offset: size }
150
+ end
151
+ return { offset: offset } if size == offset
152
+
153
+ chunk = +""
154
+ File.open(path, "rb") do |file|
155
+ file.seek(offset)
156
+ chunk = file.read(size - offset).to_s
157
+ end
158
+
159
+ @tail_buffer << chunk
160
+ lines = @tail_buffer.split("\n", -1)
161
+ @tail_buffer = lines.pop || +""
162
+ wrote = false
163
+
164
+ lines.each do |line|
165
+ json_line = "#{line}\n"
166
+ event = parse_event(json_line)
167
+ if emit_line?(json_line, parsed: event)
168
+ $stdout.write(json_line)
169
+ wrote = true
170
+ end
171
+ return { code: 0, offset: size } if target_exited?(event)
172
+ end
173
+
174
+ $stdout.flush if wrote
175
+ { offset: size }
176
+ end
177
+
178
+ def emit_line?(line, parsed: nil)
179
+ return true unless @options[:from]
180
+
181
+ event = parsed || parse_event(line)
182
+ return false unless event
183
+
184
+ timestamp = event_timestamp(event)
185
+ return false unless timestamp
186
+
187
+ timestamp >= @options[:from]
188
+ end
189
+
190
+ def parse_event(line)
191
+ JSON.parse(line)
192
+ rescue JSON::ParserError
193
+ nil
194
+ end
195
+
196
+ def event_timestamp(event)
197
+ value = event["ts"].to_s
198
+ return nil if value.empty?
199
+
200
+ Time.iso8601(value)
201
+ rescue ArgumentError
202
+ nil
203
+ end
204
+
205
+ def target_exited?(event)
206
+ return false unless event
207
+ return false unless event["type"] == "exited"
208
+
209
+ Harnex.id_key(event["id"].to_s) == Harnex.id_key(@options[:id])
210
+ end
211
+ end
212
+ end