harnex 0.3.4 → 0.4.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: e4d7c194ba2ba10ee075e6e53aa3eaf291cbfc08eca1d89cd291955834230c01
4
+ data.tar.gz: e3bafc087ae74a484be3ee121cce0d8f351c6b381ce244d5da032e48c85da4db
5
5
  SHA512:
6
- metadata.gz: 1dd3ad5ef1cf5bb17d6a92ea6b27b746ef47a8e1a8e58b1e2a98936946f63f58d8d297d7493ffc3ab3968e25ac26415ae73c44524423a83f36c5951b4e729813
7
- data.tar.gz: 39cf0ed49b278a10ff57767d6cc166232e7c30ca9eed31853d079a2a437dcd0ee4709a8a13959e91bd106d8ce7b1e1b043b64a6df0fd24988a925266c488769a
6
+ metadata.gz: e3737c2aa49fb223c75cfc0d997a52bb72029b4c1a7a11affdd05a16c685e4edc42384ff0cfc03e63e4b4abcc9228c7f1d37c408c074538392c75d13be4e1e5e
7
+ data.tar.gz: 0b096fb9d95f22b812fc43fc69b4f8546e090d76c669383b8d71b34a048aa8eed46510f7ce9be27e0da3eab449a610c13fd6a338d8e1c202649d235df7052154
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)
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
@@ -6,10 +6,12 @@ module Harnex
6
6
  class Runner
7
7
  DEFAULT_TIMEOUT = 5.0
8
8
  KNOWN_FLAGS = %w[
9
- --id --description --detach --tmux --host --port --watch --context --timeout --inbox-ttl --help
9
+ --id --description --detach --tmux --host --port --watch --watch-file
10
+ --stall-after --max-resumes --preset --context --timeout --inbox-ttl --help
10
11
  ].freeze
11
12
  VALUE_FLAGS = %w[
12
- --id --description --host --port --watch --context --timeout --inbox-ttl
13
+ --id --description --host --port --watch --watch-file --stall-after
14
+ --max-resumes --preset --context --timeout --inbox-ttl
13
15
  ].freeze
14
16
 
15
17
  def self.usage(program_name = "harnex run")
@@ -23,13 +25,20 @@ module Harnex
23
25
  --tmux [NAME] Run in a tmux window (implies --detach)
24
26
  --host HOST Bind host for the local API (default: #{DEFAULT_HOST})
25
27
  --port PORT Force a specific local API port
26
- --watch PATH Auto-send a file-change hook on modification
28
+ --watch Enable blocking babysitter mode (foreground only)
29
+ --stall-after DUR Force-resume threshold (default: #{RunWatcher::DEFAULT_STALL_AFTER_S.to_i}s)
30
+ --max-resumes N Max forced resumes before escalation (default: #{RunWatcher::DEFAULT_MAX_RESUMES})
31
+ --preset NAME Watch preset: impl, plan, gate (requires --watch)
32
+ --watch-file PATH Auto-send a file-change hook on modification
27
33
  --context TEXT Inject as the initial prompt (prepends session header)
28
34
  --timeout SECS Max seconds to wait for detached registration (default: #{DEFAULT_TIMEOUT})
29
35
  --inbox-ttl SECS Expire queued inbox messages after SECS (default: #{Inbox::DEFAULT_TTL})
30
36
  -h, --help Show this help
31
37
 
32
38
  Notes:
39
+ Compatibility: `--watch PATH` and `--watch=PATH` still configure file-hook mode.
40
+ Bare `--watch` enables the babysitter.
41
+ Explicit --stall-after/--max-resumes values override --preset defaults.
33
42
  CLIs with smart prompt detection: #{Adapters.known.join(', ')}
34
43
  Any other CLI name is launched with generic wrapping.
35
44
  Wrapper options may appear before or after <cli>.
@@ -43,6 +52,12 @@ module Harnex
43
52
  description: nil,
44
53
  host: DEFAULT_HOST,
45
54
  port: nil,
55
+ watch_enabled: false,
56
+ stall_after_s: RunWatcher::DEFAULT_STALL_AFTER_S,
57
+ stall_after_explicit: false,
58
+ max_resumes: RunWatcher::DEFAULT_MAX_RESUMES,
59
+ max_resumes_explicit: false,
60
+ preset: nil,
46
61
  watch: nil,
47
62
  context: nil,
48
63
  detach: false,
@@ -69,8 +84,12 @@ module Harnex
69
84
  effective_child_args = apply_context(child_args)
70
85
  adapter = Harnex.build_adapter(cli_name, effective_child_args)
71
86
  @options[:detach] = true if @options[:tmux]
87
+ validate_watch_mode!
88
+ resolve_watch_preset!
72
89
 
73
- if @options[:detach]
90
+ if @options[:watch_enabled]
91
+ run_watch_mode(adapter, repo_root)
92
+ elsif @options[:detach]
74
93
  run_detached(adapter, cli_name, child_args, repo_root)
75
94
  else
76
95
  run_foreground(adapter, repo_root)
@@ -90,10 +109,25 @@ module Harnex
90
109
  if @options[:tmux]
91
110
  run_in_tmux(cli_name, child_args, repo_root)
92
111
  else
93
- run_headless(adapter, repo_root)
112
+ result = run_headless(adapter, repo_root)
113
+ result[:exit_code]
94
114
  end
95
115
  end
96
116
 
117
+ def run_watch_mode(adapter, repo_root)
118
+ Session.validate_binary!(adapter.build_command)
119
+
120
+ result = run_headless(adapter, repo_root, emit_payload: false)
121
+ return result[:exit_code] unless result[:ok]
122
+
123
+ RunWatcher.new(
124
+ id: @options[:id],
125
+ repo_root: repo_root,
126
+ stall_after_s: @options[:stall_after_s],
127
+ max_resumes: @options[:max_resumes]
128
+ ).run
129
+ end
130
+
97
131
  def run_in_tmux(cli_name, child_args, repo_root)
98
132
  harnex_bin = File.expand_path("../../../bin/harnex", __dir__)
99
133
  tmux_cmd = [harnex_bin, "run", cli_name]
@@ -101,7 +135,7 @@ module Harnex
101
135
  tmux_cmd += ["--description", @options[:description]] if @options[:description]
102
136
  tmux_cmd += ["--host", @options[:host]]
103
137
  tmux_cmd += ["--port", @options[:port].to_s] if @options[:port]
104
- tmux_cmd += ["--watch", @options[:watch]] if @options[:watch]
138
+ tmux_cmd += ["--watch-file", @options[:watch]] if @options[:watch]
105
139
  tmux_cmd += ["--context", @options[:context]] if @options[:context]
106
140
  tmux_cmd += ["--inbox-ttl", @options[:inbox_ttl].to_s]
107
141
  tmux_cmd += ["--"] + child_args unless child_args.empty?
@@ -137,7 +171,7 @@ module Harnex
137
171
  0
138
172
  end
139
173
 
140
- def run_headless(adapter, repo_root)
174
+ def run_headless(adapter, repo_root, emit_payload: true)
141
175
  log_dir = File.join(Harnex::STATE_DIR, "logs")
142
176
  FileUtils.mkdir_p(log_dir)
143
177
  log_path = File.join(log_dir, "#{@options[:id]}.log")
@@ -159,7 +193,7 @@ module Harnex
159
193
  Process.detach(child_pid)
160
194
 
161
195
  registry = wait_for_registration(repo_root)
162
- return registration_timeout(@options[:id]) unless registry
196
+ return { ok: false, exit_code: registration_timeout(@options[:id]) } unless registry
163
197
 
164
198
  payload = {
165
199
  ok: true,
@@ -172,12 +206,19 @@ module Harnex
172
206
  output_log_path: Harnex.output_log_path(repo_root, @options[:id])
173
207
  }
174
208
  payload[:description] = @options[:description] if @options[:description]
175
- puts JSON.generate(payload)
176
- 0
209
+ puts JSON.generate(payload) if emit_payload
210
+ { ok: true, exit_code: 0, registry: registry, payload: payload }
177
211
  end
178
212
 
179
213
  private
180
214
 
215
+ def validate_watch_mode!
216
+ return unless @options[:watch_enabled]
217
+ return unless @options[:detach]
218
+
219
+ raise OptionParser::InvalidOption, "--watch is only supported in foreground mode"
220
+ end
221
+
181
222
  def validate_unique_id!(repo_root)
182
223
  existing = Harnex.read_registry(repo_root, @options[:id])
183
224
  return unless existing
@@ -294,10 +335,51 @@ module Harnex
294
335
  when /\A--port=(.+)\z/
295
336
  @options[:port] = Integer(required_option_value("--port", Regexp.last_match(1)))
296
337
  when "--watch"
297
- index += 1
298
- @options[:watch] = required_option_value(arg, argv[index])
338
+ value = argv[index + 1]
339
+ if value.nil? || value == "--" || wrapper_option_token?(value)
340
+ @options[:watch_enabled] = true
341
+ else
342
+ index += 1
343
+ @options[:watch] = required_option_value(arg, argv[index])
344
+ end
299
345
  when /\A--watch=(.+)\z/
300
346
  @options[:watch] = required_option_value("--watch", Regexp.last_match(1))
347
+ when "--watch-file"
348
+ index += 1
349
+ @options[:watch] = required_option_value(arg, argv[index])
350
+ when /\A--watch-file=(.+)\z/
351
+ @options[:watch] = required_option_value("--watch-file", Regexp.last_match(1))
352
+ when "--stall-after"
353
+ index += 1
354
+ @options[:stall_after_s] = Harnex.parse_duration_seconds(
355
+ required_option_value(arg, argv[index]),
356
+ option_name: "--stall-after"
357
+ )
358
+ @options[:stall_after_explicit] = true
359
+ when /\A--stall-after=(.+)\z/
360
+ @options[:stall_after_s] = Harnex.parse_duration_seconds(
361
+ required_option_value("--stall-after", Regexp.last_match(1)),
362
+ option_name: "--stall-after"
363
+ )
364
+ @options[:stall_after_explicit] = true
365
+ when "--max-resumes"
366
+ index += 1
367
+ @options[:max_resumes] = parse_non_negative_integer(
368
+ required_option_value(arg, argv[index]),
369
+ option_name: "--max-resumes"
370
+ )
371
+ @options[:max_resumes_explicit] = true
372
+ when /\A--max-resumes=(.+)\z/
373
+ @options[:max_resumes] = parse_non_negative_integer(
374
+ required_option_value("--max-resumes", Regexp.last_match(1)),
375
+ option_name: "--max-resumes"
376
+ )
377
+ @options[:max_resumes_explicit] = true
378
+ when "--preset"
379
+ index += 1
380
+ @options[:preset] = required_option_value(arg, argv[index])
381
+ when /\A--preset=(.+)\z/
382
+ @options[:preset] = required_option_value("--preset", Regexp.last_match(1))
301
383
  when "--context"
302
384
  index += 1
303
385
  @options[:context] = required_option_value(arg, argv[index])
@@ -358,7 +440,9 @@ module Harnex
358
440
  nil
359
441
  when *VALUE_FLAGS
360
442
  index += 1
361
- when /\A--(?:id|description|host|port|watch|context|timeout|inbox-ttl)=/
443
+ when /\A--(?:id|description|host|port|watch|watch-file|stall-after|max-resumes|context|timeout|inbox-ttl)=/
444
+ nil
445
+ when /\A--preset=/
362
446
  nil
363
447
  else
364
448
  return true
@@ -372,7 +456,37 @@ module Harnex
372
456
  def wrapper_option_token?(arg)
373
457
  KNOWN_FLAGS.include?(arg) ||
374
458
  arg == "-h" ||
375
- arg.start_with?("--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--context=", "--timeout=", "--inbox-ttl=")
459
+ arg.start_with?(
460
+ "--id=", "--description=", "--tmux=", "--host=", "--port=", "--watch=", "--watch-file=",
461
+ "--stall-after=", "--max-resumes=", "--preset=", "--context=", "--timeout=", "--inbox-ttl="
462
+ )
463
+ end
464
+
465
+ def resolve_watch_preset!
466
+ preset_name = @options[:preset]
467
+ return if preset_name.nil?
468
+
469
+ unless @options[:watch_enabled]
470
+ raise "harnex run: --preset requires --watch"
471
+ end
472
+
473
+ preset = WatchPresets.fetch(preset_name)
474
+ unless preset
475
+ valid = WatchPresets.valid_names.join(", ")
476
+ raise "harnex run: unknown --preset #{preset_name.inspect} (valid: #{valid})"
477
+ end
478
+
479
+ @options[:stall_after_s] = preset[:stall_after_s] unless @options[:stall_after_explicit]
480
+ @options[:max_resumes] = preset[:max_resumes] unless @options[:max_resumes_explicit]
481
+ end
482
+
483
+ def parse_non_negative_integer(value, option_name:)
484
+ integer = Integer(value)
485
+ raise OptionParser::InvalidArgument, "#{option_name} must be 0 or greater" if integer.negative?
486
+
487
+ integer
488
+ rescue ArgumentError
489
+ raise OptionParser::InvalidArgument, "#{option_name} must be an integer"
376
490
  end
377
491
 
378
492
  def default_inbox_ttl
@@ -98,7 +98,7 @@ module Harnex
98
98
  end
99
99
 
100
100
  def render_table(sessions)
101
- columns = ["ID", "CLI", "PID", "PORT", "AGE", "STATE", "REPO", "DESC"]
101
+ columns = ["ID", "CLI", "PID", "PORT", "AGE", "IDLE", "STATE", "REPO", "DESC"]
102
102
 
103
103
  rows = sessions.map { |session| table_row(session, columns) }
104
104
  widths = columns.to_h { |column| [column, ([column.length] + rows.map { |row| row.fetch(column).length }).max] }
@@ -117,6 +117,7 @@ module Harnex
117
117
  "PID" => session["pid"].to_s,
118
118
  "PORT" => session["port"].to_s,
119
119
  "AGE" => timeago(session["started_at"]),
120
+ "IDLE" => format_idle(session["log_idle_s"]),
120
121
  "STATE" => session.dig("input_state", "state").to_s.empty? ? "-" : session.dig("input_state", "state").to_s,
121
122
  "DESC" => truncate(session["description"])
122
123
  }
@@ -133,7 +134,22 @@ module Harnex
133
134
 
134
135
  seconds = (Time.now - Time.parse(timestamp.to_s)).to_i
135
136
  seconds = 0 if seconds.negative?
137
+ compact_duration(seconds)
138
+ rescue StandardError
139
+ timestamp.to_s
140
+ end
141
+
142
+ def format_idle(idle_seconds)
143
+ return "-" if idle_seconds.nil?
144
+
145
+ seconds = Integer(idle_seconds)
146
+ seconds = 0 if seconds.negative?
147
+ compact_duration(seconds)
148
+ rescue StandardError
149
+ "-"
150
+ end
136
151
 
152
+ def compact_duration(seconds)
137
153
  case seconds
138
154
  when 0...60
139
155
  "#{seconds}s"
@@ -144,8 +160,6 @@ module Harnex
144
160
  else
145
161
  "#{seconds / 86_400}d"
146
162
  end
147
- rescue StandardError
148
- timestamp.to_s
149
163
  end
150
164
 
151
165
  def truncate(value)
@@ -0,0 +1,209 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Harnex
6
+ class RunWatcher
7
+ DEFAULT_STALL_AFTER_S = 8 * 60.0
8
+ DEFAULT_MAX_RESUMES = 1
9
+ POLL_INTERVAL_S = 60.0
10
+ MAX_STATUS_ERRORS = 3
11
+ RESUME_TEXT = "resume"
12
+
13
+ def initialize(
14
+ id:,
15
+ repo_root:,
16
+ stall_after_s: DEFAULT_STALL_AFTER_S,
17
+ max_resumes: DEFAULT_MAX_RESUMES,
18
+ poll_interval_s: POLL_INTERVAL_S,
19
+ sleeper: nil,
20
+ monotonic_clock: nil,
21
+ out: $stdout,
22
+ err: $stderr
23
+ )
24
+ @id = Harnex.normalize_id(id)
25
+ @repo_root = repo_root
26
+ @stall_after_s = Float(stall_after_s)
27
+ @max_resumes = Integer(max_resumes)
28
+ @poll_interval_s = Float(poll_interval_s)
29
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
30
+ @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
31
+ @out = out
32
+ @err = err
33
+ end
34
+
35
+ def run
36
+ polls = 0
37
+ resumes = 0
38
+ final_state = "unknown"
39
+ outcome = :error
40
+ status_errors = 0
41
+ start_at = now
42
+
43
+ @out.puts(
44
+ "harnex watch: id=#{@id} stall-after=#{format_duration(@stall_after_s)} " \
45
+ "max-resumes=#{@max_resumes} poll=#{format_duration(@poll_interval_s)}"
46
+ )
47
+
48
+ loop do
49
+ polls += 1
50
+ snapshot = fetch_snapshot
51
+
52
+ case snapshot[:kind]
53
+ when :exited
54
+ final_state = "exited"
55
+ outcome = :exited
56
+ @out.puts("harnex watch: session exited")
57
+ break
58
+ when :error
59
+ if snapshot[:fatal]
60
+ @err.puts("harnex watch: #{snapshot[:error]}")
61
+ outcome = :error
62
+ break
63
+ end
64
+
65
+ status_errors += 1
66
+ if status_errors >= MAX_STATUS_ERRORS
67
+ @err.puts("harnex watch: #{snapshot[:error]} (status retry limit reached)")
68
+ outcome = :error
69
+ break
70
+ end
71
+ when :status
72
+ status_errors = 0
73
+ final_state = snapshot[:agent_state]
74
+
75
+ if snapshot[:stalled]
76
+ if resumes < @max_resumes
77
+ send_resume(snapshot[:registry])
78
+ resumes += 1
79
+ @out.puts(
80
+ "harnex watch: resume #{resumes}/#{@max_resumes} " \
81
+ "(idle=#{format_duration(snapshot[:idle_seconds])}, state=#{final_state})"
82
+ )
83
+ else
84
+ outcome = :escalated
85
+ @out.puts("harnex watch: max resumes reached, escalating")
86
+ break
87
+ end
88
+ end
89
+ end
90
+
91
+ @sleeper.call(@poll_interval_s)
92
+ end
93
+
94
+ elapsed = (now - start_at).round(1)
95
+ @out.puts(
96
+ "harnex watch: summary id=#{@id} polls=#{polls} resumes=#{resumes} " \
97
+ "final_state=#{final_state} outcome=#{outcome} elapsed_s=#{elapsed}"
98
+ )
99
+ outcome_to_exit_code(outcome)
100
+ rescue StandardError => e
101
+ @err.puts("harnex watch: #{e.message}")
102
+ 1
103
+ end
104
+
105
+ private
106
+
107
+ def fetch_snapshot
108
+ registry = Harnex.read_registry(@repo_root, @id)
109
+ return { kind: :exited } unless registry
110
+
111
+ status = fetch_status(registry)
112
+ return status if status[:kind] == :error
113
+
114
+ payload = status[:payload]
115
+ unless payload.key?("log_idle_s")
116
+ return {
117
+ kind: :error,
118
+ fatal: true,
119
+ error: "status payload missing log_idle_s; upgrade to a Layer-1+ harnex build"
120
+ }
121
+ end
122
+
123
+ agent_state = payload["agent_state"].to_s.strip
124
+ return { kind: :exited } if agent_state == "exited"
125
+
126
+ idle_seconds = parse_idle_seconds(payload["log_idle_s"])
127
+ {
128
+ kind: :status,
129
+ registry: registry,
130
+ agent_state: agent_state.empty? ? "unknown" : agent_state,
131
+ idle_seconds: idle_seconds,
132
+ stalled: !idle_seconds.nil? && idle_seconds >= @stall_after_s
133
+ }
134
+ end
135
+
136
+ def fetch_status(registry)
137
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/status")
138
+ request = Net::HTTP::Get.new(uri)
139
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
140
+
141
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
142
+ http.request(request)
143
+ end
144
+
145
+ unless response.is_a?(Net::HTTPSuccess)
146
+ return { kind: :error, error: "status request failed with HTTP #{response.code} for session #{@id}" }
147
+ end
148
+
149
+ { kind: :status_payload, payload: JSON.parse(response.body) }
150
+ rescue StandardError => e
151
+ { kind: :error, error: "status request failed for session #{@id}: #{e.message}" }
152
+ end
153
+
154
+ def send_resume(registry)
155
+ uri = URI("http://#{registry.fetch('host')}:#{registry.fetch('port')}/send")
156
+ request = Net::HTTP::Post.new(uri)
157
+ request["Authorization"] = "Bearer #{registry['token']}" if registry["token"]
158
+ request["Content-Type"] = "application/json"
159
+ request.body = JSON.generate(
160
+ text: RESUME_TEXT,
161
+ submit: true,
162
+ enter_only: false,
163
+ force: true
164
+ )
165
+
166
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
167
+ http.request(request)
168
+ end
169
+
170
+ return if response.is_a?(Net::HTTPSuccess)
171
+
172
+ raise "resume send failed with HTTP #{response.code} for session #{@id}"
173
+ rescue StandardError => e
174
+ raise "resume send failed for session #{@id}: #{e.message}"
175
+ end
176
+
177
+ def parse_idle_seconds(value)
178
+ return nil if value.nil?
179
+
180
+ seconds = Integer(value)
181
+ seconds.negative? ? 0 : seconds
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ def outcome_to_exit_code(outcome)
187
+ case outcome
188
+ when :exited
189
+ 0
190
+ when :escalated
191
+ 2
192
+ else
193
+ 1
194
+ end
195
+ end
196
+
197
+ def format_duration(seconds)
198
+ value = seconds.to_f
199
+ return "#{value.round(1)}s" if value < 60
200
+ return "#{(value / 60).round(1)}m" if value < 3600
201
+
202
+ "#{(value / 3600).round(1)}h"
203
+ end
204
+
205
+ def now
206
+ @monotonic_clock.call
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,17 @@
1
+ module Harnex
2
+ module WatchPresets
3
+ TABLE = {
4
+ "impl" => { stall_after_s: 8 * 60.0, max_resumes: 1 }.freeze,
5
+ "plan" => { stall_after_s: 3 * 60.0, max_resumes: 2 }.freeze,
6
+ "gate" => { stall_after_s: 15 * 60.0, max_resumes: 0 }.freeze
7
+ }.freeze
8
+
9
+ def self.fetch(name)
10
+ TABLE[name]
11
+ end
12
+
13
+ def self.valid_names
14
+ TABLE.keys
15
+ end
16
+ end
17
+ end
data/lib/harnex/core.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require "digest"
2
2
  require "fileutils"
3
+ require "optparse"
3
4
  require "securerandom"
4
5
  require "set"
5
6
  require "socket"
@@ -37,6 +38,32 @@ module Harnex
37
38
  File.expand_path(path)
38
39
  end
39
40
 
41
+ def parse_duration_seconds(value, option_name:)
42
+ text = value.to_s.strip
43
+ raise OptionParser::InvalidArgument, "#{option_name} requires a value" if text.empty?
44
+
45
+ match = text.match(/\A([0-9]+(?:\.[0-9]+)?)([smhSMH]?)\z/)
46
+ unless match
47
+ raise OptionParser::InvalidArgument,
48
+ "#{option_name} must be a positive duration (examples: 30, 30s, 5m, 2h)"
49
+ end
50
+
51
+ amount = Float(match[1])
52
+ multiplier =
53
+ case match[2].downcase
54
+ when "", "s" then 1.0
55
+ when "m" then 60.0
56
+ when "h" then 3600.0
57
+ else
58
+ raise OptionParser::InvalidArgument, "#{option_name} has an unsupported duration suffix"
59
+ end
60
+
61
+ seconds = amount * multiplier
62
+ raise OptionParser::InvalidArgument, "#{option_name} must be greater than 0" if seconds <= 0.0
63
+
64
+ seconds
65
+ end
66
+
40
67
  def repo_key(repo_root)
41
68
  Digest::SHA256.hexdigest(repo_root)[0, 16]
42
69
  end
@@ -113,6 +140,12 @@ module Harnex
113
140
  File.join(output_dir, "#{session_file_slug(repo_root, id)}.log")
114
141
  end
115
142
 
143
+ def events_log_path(repo_root, id)
144
+ events_dir = File.join(STATE_DIR, "events")
145
+ FileUtils.mkdir_p(events_dir)
146
+ File.join(events_dir, "#{session_file_slug(repo_root, id)}.jsonl")
147
+ end
148
+
116
149
  def session_file_slug(repo_root, id)
117
150
  slug = id_key(id)
118
151
  slug = "default" if slug.empty?
@@ -6,7 +6,7 @@ module Harnex
6
6
  class Session
7
7
  OUTPUT_BUFFER_LIMIT = 64 * 1024
8
8
 
9
- attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path
9
+ attr_reader :repo_root, :host, :port, :session_id, :token, :command, :pid, :id, :adapter, :watch, :inbox, :description, :output_log_path, :events_log_path
10
10
 
11
11
  def initialize(adapter:, command:, repo_root:, host:, port: nil, id: DEFAULT_ID, watch: nil, description: nil, inbox_ttl: Inbox::DEFAULT_TTL)
12
12
  @adapter = adapter
@@ -19,17 +19,21 @@ module Harnex
19
19
  @description = nil if @description.empty?
20
20
  @registry_path = Harnex.registry_path(repo_root, @id)
21
21
  @output_log_path = Harnex.output_log_path(repo_root, @id)
22
+ @events_log_path = Harnex.events_log_path(repo_root, @id)
22
23
  @session_id = SecureRandom.hex(8)
23
24
  @token = SecureRandom.hex(16)
24
25
  @port = Harnex.allocate_port(repo_root, @id, port, host: host)
25
26
  @mutex = Mutex.new
26
27
  @inject_mutex = Mutex.new
28
+ @events_mutex = Mutex.new
27
29
  @injected_count = 0
28
30
  @last_injected_at = nil
29
31
  @started_at = Time.now
30
32
  @server = nil
31
33
  @reader = nil
32
34
  @output_log = nil
35
+ @events_log = nil
36
+ @events_log_seq = 0
33
37
  @writer = nil
34
38
  @pid = nil
35
39
  @term_signal = nil
@@ -60,8 +64,10 @@ module Harnex
60
64
  def run(validate_binary: true)
61
65
  validate_binary! if validate_binary
62
66
  prepare_output_log
67
+ prepare_events_log
63
68
  @reader, @writer, @pid = PTY.spawn(child_env, *command)
64
69
  @writer.sync = true
70
+ emit_event("started", pid: @pid)
65
71
 
66
72
  install_signal_handlers
67
73
  sync_window_size
@@ -78,6 +84,7 @@ module Harnex
78
84
  _, status = Process.wait2(pid)
79
85
  @term_signal = status.signaled? ? status.termsig : nil
80
86
  @exit_code = status.exited? ? status.exitstatus : 128 + status.termsig
87
+ emit_exit_event
81
88
 
82
89
  output_thread.join(1)
83
90
  input_thread&.kill
@@ -91,6 +98,7 @@ module Harnex
91
98
  cleanup_registry
92
99
  @reader&.close unless @reader&.closed?
93
100
  @output_log&.close unless @output_log&.closed?
101
+ @events_log&.close unless @events_log&.closed?
94
102
  @writer&.close unless @writer&.closed?
95
103
  end
96
104
 
@@ -109,8 +117,10 @@ module Harnex
109
117
  started_at: @started_at.iso8601,
110
118
  last_injected_at: @last_injected_at&.iso8601,
111
119
  injected_count: @injected_count,
112
- output_log_path: output_log_path
120
+ output_log_path: output_log_path,
121
+ events_log_path: events_log_path
113
122
  }
123
+ payload.merge!(log_activity_snapshot)
114
124
  payload[:description] = description if description
115
125
 
116
126
  if watch
@@ -168,6 +178,7 @@ module Harnex
168
178
  input_state: payload[:input_state],
169
179
  force: payload[:force]
170
180
  )
181
+ .tap { emit_send_event(text, force: payload[:force]) }
171
182
  end
172
183
 
173
184
  def sync_window_size
@@ -327,6 +338,14 @@ module Harnex
327
338
  @output_log_failed = false
328
339
  end
329
340
 
341
+ def prepare_events_log
342
+ @events_log&.close unless @events_log&.closed?
343
+ @events_log = File.open(events_log_path, "ab")
344
+ @events_log.sync = true
345
+ @events_log_failed = false
346
+ @events_log_seq = 0
347
+ end
348
+
330
349
  def install_signal_handlers
331
350
  %w[INT TERM HUP QUIT].each do |signal_name|
332
351
  Signal.trap(signal_name) { forward_signal(signal_name) }
@@ -364,6 +383,60 @@ module Harnex
364
383
  warn("harnex: failed to write output log #{output_log_path}: #{e.message}")
365
384
  end
366
385
 
386
+ def emit_send_event(text, force:)
387
+ compact = text.to_s
388
+ truncated = compact.length > 200
389
+ preview = truncated ? "#{compact[0, 200]}…" : compact
390
+ emit_event("send", msg: preview, msg_truncated: truncated, forced: !!force)
391
+ end
392
+
393
+ def emit_exit_event
394
+ payload = { code: @exit_code }
395
+ payload[:signal] = @term_signal if @term_signal
396
+ emit_event("exited", **payload)
397
+ end
398
+
399
+ def emit_event(type, **payload)
400
+ @events_mutex.synchronize do
401
+ return unless @events_log
402
+
403
+ @events_log_seq += 1
404
+ event = {
405
+ schema_version: 1,
406
+ seq: @events_log_seq,
407
+ ts: Time.now.utc.iso8601,
408
+ id: id,
409
+ type: type
410
+ }.merge(payload)
411
+ @events_log.write(JSON.generate(event))
412
+ @events_log.write("\n")
413
+ @events_log.flush
414
+ end
415
+ rescue StandardError => e
416
+ return if defined?(@events_log_failed) && @events_log_failed
417
+
418
+ @events_log_failed = true
419
+ warn("harnex: failed to write events log #{events_log_path}: #{e.message}")
420
+ end
421
+
422
+ def log_activity_snapshot
423
+ return { log_mtime: nil, log_idle_s: nil } unless File.file?(output_log_path)
424
+ return { log_mtime: nil, log_idle_s: nil } if File.size?(output_log_path).nil?
425
+
426
+ mtime = File.mtime(output_log_path)
427
+ idle_seconds = (Time.now - mtime).to_i
428
+ idle_seconds = 0 if idle_seconds.negative?
429
+ {
430
+ log_mtime: mtime.iso8601,
431
+ log_idle_s: idle_seconds
432
+ }
433
+ rescue StandardError
434
+ {
435
+ log_mtime: nil,
436
+ log_idle_s: nil
437
+ }
438
+ end
439
+
367
440
  def screen_snapshot
368
441
  @mutex.synchronize { @output_buffer.dup }
369
442
  end
@@ -1,4 +1,4 @@
1
1
  module Harnex
2
- VERSION = "0.3.4"
3
- RELEASE_DATE = "2026-04-24"
2
+ VERSION = "0.4.0"
3
+ RELEASE_DATE = "2026-04-30"
4
4
  end
data/lib/harnex.rb CHANGED
@@ -12,12 +12,15 @@ require_relative "harnex/runtime/inbox"
12
12
  require_relative "harnex/runtime/file_change_hook"
13
13
  require_relative "harnex/runtime/api_server"
14
14
  require_relative "harnex/runtime/session"
15
+ require_relative "harnex/commands/watch"
16
+ require_relative "harnex/commands/watch_presets"
15
17
  require_relative "harnex/commands/run"
16
18
  require_relative "harnex/commands/send"
17
19
  require_relative "harnex/commands/wait"
18
20
  require_relative "harnex/commands/stop"
19
21
  require_relative "harnex/commands/status"
20
22
  require_relative "harnex/commands/logs"
23
+ require_relative "harnex/commands/events"
21
24
  require_relative "harnex/commands/pane"
22
25
  require_relative "harnex/commands/recipes"
23
26
  require_relative "harnex/commands/guide"
@@ -8,6 +8,11 @@ description: Spawn an accountability partner for long-running harnex sessions. U
8
8
  For any long-running or unattended work, spawn a **buddy** — a second harnex
9
9
  agent that watches the worker and nudges it if it stalls.
10
10
 
11
+ For plain stall recovery (force-resume on inactivity), prefer
12
+ `harnex run --watch --preset impl`. Use a buddy when you need reasoning that
13
+ policy checks cannot provide (doc drift, semantic checks, multi-session
14
+ correlation).
15
+
11
16
  The buddy is an LLM, so it has intelligence for free. It reads the worker's
12
17
  screen, reasons about whether it's stuck, and composes a meaningful nudge.
13
18
 
@@ -119,11 +119,27 @@ harnex run codex --id cx-impl-NN --tmux cx-impl-NN \
119
119
  --context "Read and execute /tmp/task-impl-NN.md"
120
120
  ```
121
121
 
122
+ ### Built-in monitoring (`--watch`)
123
+
124
+ For unattended implementation runs where you only need stall policy (not
125
+ Claude-side reasoning), bundle dispatch and monitoring in one command:
126
+
127
+ ```bash
128
+ harnex run codex --id cx-impl-42 --tmux cx-impl-42 --watch --preset impl
129
+ ```
130
+
131
+ `--preset impl` applies the standard 8m stall threshold with one forced resume.
132
+ Trade-off: `--watch` is foreground-blocking and policy-only (`stall-after` +
133
+ `max-resumes`). Use pane polling (and buddy when needed) for richer reasoning.
134
+
122
135
  ## 2. Watch
123
136
 
124
137
  Poll the agent's screen with `harnex pane`. Checking is cheap — a 20-line
125
138
  tail is a few hundred bytes.
126
139
 
140
+ For structured orchestration, prefer `harnex events --id <id>` over pane-text
141
+ scraping.
142
+
127
143
  **Default: poll every 30 seconds.** This is fine for most work. The check
128
144
  itself costs almost nothing and catches completion quickly.
129
145
 
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.3.4
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jikku Jose
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-24 00:00:00.000000000 Z
11
+ date: 2026-04-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A local PTY harness that wraps terminal AI agents (Claude, Codex) and
14
14
  adds a control plane for discovery, messaging, and coordination.
@@ -32,6 +32,7 @@ files:
32
32
  - lib/harnex/adapters/codex.rb
33
33
  - lib/harnex/adapters/generic.rb
34
34
  - lib/harnex/cli.rb
35
+ - lib/harnex/commands/events.rb
35
36
  - lib/harnex/commands/guide.rb
36
37
  - lib/harnex/commands/logs.rb
37
38
  - lib/harnex/commands/pane.rb
@@ -42,6 +43,8 @@ files:
42
43
  - lib/harnex/commands/status.rb
43
44
  - lib/harnex/commands/stop.rb
44
45
  - lib/harnex/commands/wait.rb
46
+ - lib/harnex/commands/watch.rb
47
+ - lib/harnex/commands/watch_presets.rb
45
48
  - lib/harnex/core.rb
46
49
  - lib/harnex/runtime/api_server.rb
47
50
  - lib/harnex/runtime/file_change_hook.rb