harnex 0.3.3 → 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: 3c56e3cb64f20e24e32b7f46c4be98b23715407f5f990f79ba1b702720a5130b
4
- data.tar.gz: 1c88ad05842287252453878522baba77db4b8388fa6166d3ef7eaf8127b0eaa1
3
+ metadata.gz: e4d7c194ba2ba10ee075e6e53aa3eaf291cbfc08eca1d89cd291955834230c01
4
+ data.tar.gz: e3bafc087ae74a484be3ee121cce0d8f351c6b381ce244d5da032e48c85da4db
5
5
  SHA512:
6
- metadata.gz: 281b2f232bddcaf24d391a68fc331c73f7d43809d2f40d18848e417da9e50b999c330105a474f99ffa080528ccbeb6139da31c785604cffaab6ce76f4fe16584
7
- data.tar.gz: 4e36010a2d17c5541e95c75179941b9fa282d2093237aabd0d1748d5b5c5c374eb4e67852d1d06b674ab039cdc7106c8f71de9bf64b9a99a737ec6e861b58fc6
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)
@@ -503,19 +559,21 @@ and `Inbox` classes use `ConditionVariable` for signaling.
503
559
 
504
560
  ## Skill Files
505
561
 
506
- Harnex ships a skill file that teaches AI agents how to use
507
- harnex commands. The file lives at:
562
+ Harnex ships bundled skills that teach agents the orchestration workflow and
563
+ dispatch discipline. The canonical collaboration skill is:
508
564
 
509
565
  ```
510
- skills/harnex/SKILL.md
566
+ skills/harnex-dispatch/SKILL.md
511
567
  ```
512
568
 
513
569
  ### What's in the skill
514
570
 
515
- The skill tells agents:
571
+ The dispatch skill tells agents:
516
572
 
517
573
  - How to detect they're inside a harnex session (env vars)
518
- - How to send messages, check status, spawn workers
574
+ - How to define return channels before delegation
575
+ - How to send short, file-referenced tasks with explicit reply instructions
576
+ - How to send messages, check status, spawn workers, and stop safely
519
577
  - How to use `--context`, `--force`, `--no-wait`
520
578
  - Relay header format and behavior
521
579
  - Collaboration patterns (reply, supervisor, file watch)
@@ -531,8 +589,8 @@ Skill files use YAML frontmatter:
531
589
 
532
590
  ```yaml
533
591
  ---
534
- name: harnex
535
- description: Collaborate with other AI agents...
592
+ name: harnex-dispatch
593
+ description: Fire & Watch dispatch pattern...
536
594
  allowed-tools: Bash(harnex *)
537
595
  ---
538
596
  ```
@@ -540,41 +598,36 @@ allowed-tools: Bash(harnex *)
540
598
  The `allowed-tools` field grants the agent permission to run
541
599
  `harnex` commands without asking for approval each time.
542
600
 
543
- ### Symlinking the skill
601
+ ### Installing bundled skills
544
602
 
545
- To make the skill available globally (not just in the harnex
546
- repo), symlink it into each agent's skill directory:
603
+ Use the installer command instead of manual symlinks:
547
604
 
548
605
  ```bash
549
- # For Claude Code
550
- ln -s /path/to/harnex/skills/harnex \
551
- ~/.claude/skills/harnex
552
-
553
- # For Codex
554
- ln -s /path/to/harnex/skills/harnex \
555
- ~/.codex/skills/harnex
606
+ harnex skills install # all canonical skills
607
+ harnex skills install harnex # compatibility alias -> harnex-dispatch
608
+ harnex skills install --local # install into current repo only
556
609
  ```
557
610
 
558
- After symlinking, any Claude or Codex session — in any repo —
559
- can use harnex commands. The skill activates automatically
560
- when the user mentions agent collaboration or when a relay
561
- message arrives.
611
+ Compatibility aliases accepted by the installer:
612
+
613
+ - `harnex` -> `harnex-dispatch`
614
+ - `dispatch` -> `harnex-dispatch`
615
+ - `chain-implement` -> `harnex-chain`
562
616
 
563
617
  ### Skill directory structure
564
618
 
565
619
  ```
566
620
  ~/.claude/skills/
567
- └── harnex -> /path/to/harnex/skills/harnex
621
+ └── harnex-dispatch
568
622
  └── SKILL.md
569
623
 
570
624
  ~/.codex/skills/
571
- └── harnex -> /path/to/harnex/skills/harnex
625
+ └── harnex-dispatch -> ~/.claude/skills/harnex-dispatch
572
626
  └── SKILL.md
573
627
  ```
574
628
 
575
- The symlink points to the `skills/harnex/` directory (not the
576
- file directly), so updates to `SKILL.md` in the repo are
577
- picked up immediately.
629
+ Deprecated installed names (`harnex`, `dispatch`, `chain-implement`) are
630
+ cleaned automatically during install and uninstall.
578
631
 
579
632
  ## Known Limitations
580
633
 
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