harnex 0.12.0 → 0.29.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +592 -0
  3. data/GUIDE.md +222 -68
  4. data/README.md +78 -48
  5. data/TECHNICAL.md +235 -98
  6. data/agents/gpt.md +60 -0
  7. data/docs/agents.md +119 -0
  8. data/docs/dispatch-telemetry.md +184 -50
  9. data/docs/events.md +20 -7
  10. data/docs/lanes.md +202 -0
  11. data/docs/mcp.md +102 -0
  12. data/docs/pi-rpc.md +81 -7
  13. data/docs/upgrading-to-0.29.md +87 -0
  14. data/guides/01_dispatch.md +101 -34
  15. data/guides/04_monitoring.md +223 -25
  16. data/guides/05_naming.md +0 -1
  17. data/guides/06_claude.md +96 -0
  18. data/lib/harnex/adapters/base.rb +4 -8
  19. data/lib/harnex/adapters/pi.rb +92 -12
  20. data/lib/harnex/adapters.rb +1 -16
  21. data/lib/harnex/artifact_report.rb +206 -12
  22. data/lib/harnex/cli.rb +68 -50
  23. data/lib/harnex/commands/ask.rb +278 -0
  24. data/lib/harnex/commands/delegate.rb +202 -0
  25. data/lib/harnex/commands/doctor.rb +34 -36
  26. data/lib/harnex/commands/events.rb +3 -3
  27. data/lib/harnex/commands/history.rb +2 -8
  28. data/lib/harnex/commands/land.rb +150 -0
  29. data/lib/harnex/commands/lanes.rb +84 -0
  30. data/lib/harnex/commands/logs.rb +3 -3
  31. data/lib/harnex/commands/mcp.rb +208 -0
  32. data/lib/harnex/commands/orchestration.rb +88 -9
  33. data/lib/harnex/commands/pane.rb +3 -3
  34. data/lib/harnex/commands/preflight.rb +566 -0
  35. data/lib/harnex/commands/reply.rb +125 -0
  36. data/lib/harnex/commands/result.rb +108 -0
  37. data/lib/harnex/commands/run.rb +248 -64
  38. data/lib/harnex/commands/send.rb +121 -16
  39. data/lib/harnex/commands/status.rb +32 -4
  40. data/lib/harnex/commands/stop.rb +14 -4
  41. data/lib/harnex/commands/telemetry.rb +19 -1
  42. data/lib/harnex/commands/wait.rb +421 -56
  43. data/lib/harnex/commands/watch.rb +134 -21
  44. data/lib/harnex/context_file.rb +85 -0
  45. data/lib/harnex/core.rb +100 -15
  46. data/lib/harnex/dispatch_history.rb +157 -10
  47. data/lib/harnex/lanes.rb +513 -0
  48. data/lib/harnex/mcp.rb +362 -0
  49. data/lib/harnex/orchestration.rb +128 -5
  50. data/lib/harnex/owned_process_group.rb +50 -0
  51. data/lib/harnex/owner_stream.rb +399 -0
  52. data/lib/harnex/pricing.rb +4 -9
  53. data/lib/harnex/runtime/activity_tracker.rb +107 -0
  54. data/lib/harnex/runtime/api_server.rb +11 -1
  55. data/lib/harnex/runtime/completion_notifier.rb +90 -11
  56. data/lib/harnex/runtime/inbox.rb +26 -7
  57. data/lib/harnex/runtime/message.rb +5 -2
  58. data/lib/harnex/runtime/runtime_budget.rb +96 -0
  59. data/lib/harnex/runtime/session.rb +684 -294
  60. data/lib/harnex/runtime/stop_request.rb +39 -0
  61. data/lib/harnex/stall.rb +46 -0
  62. data/lib/harnex/telemetry_reconciler.rb +42 -20
  63. data/lib/harnex/terminal_status.rb +5 -5
  64. data/lib/harnex/version.rb +2 -2
  65. data/lib/harnex.rb +16 -0
  66. data/recipes/01_fire_and_watch.md +14 -14
  67. data/recipes/02_chain_implement.md +35 -31
  68. data/recipes/03_buddy.md +1 -1
  69. metadata +34 -12
  70. data/docs/codex-appserver.md +0 -175
  71. data/lib/harnex/adapters/codex.rb +0 -160
  72. data/lib/harnex/adapters/codex_appserver.rb +0 -407
  73. data/lib/harnex/codex/app_server/client.rb +0 -347
data/lib/harnex/cli.rb CHANGED
@@ -37,6 +37,24 @@ module Harnex
37
37
  AgentsGuide.new(@argv.drop(1)).run
38
38
  when "doctor"
39
39
  Doctor.new(@argv.drop(1)).run
40
+ when "preflight"
41
+ Preflight.new(@argv.drop(1)).run
42
+ when "ask"
43
+ Ask.new(@argv.drop(1)).run
44
+ when "delegate"
45
+ Delegate.new(@argv.drop(1)).run
46
+ when "result"
47
+ LaneResult.new(@argv.drop(1)).run
48
+ when "reply"
49
+ Reply.new(@argv.drop(1)).run
50
+ when "land"
51
+ Land.new(@argv.drop(1)).run
52
+ when "discard"
53
+ Land.new(@argv.drop(1), discard: true).run
54
+ when "lanes"
55
+ LanesCommand.new(@argv.drop(1)).run
56
+ when "mcp"
57
+ MCPCommand.new(@argv.drop(1)).run
40
58
  when "orchestration"
41
59
  OrchestrationCommand.new(@argv.drop(1)).run
42
60
  when "artifact-report"
@@ -89,6 +107,24 @@ module Harnex
89
107
  AgentsGuide.usage
90
108
  when "doctor"
91
109
  Doctor.usage
110
+ when "preflight"
111
+ Preflight.usage
112
+ when "ask"
113
+ Ask.usage
114
+ when "delegate"
115
+ Delegate.usage
116
+ when "result"
117
+ LaneResult.usage
118
+ when "reply"
119
+ Reply.usage
120
+ when "land"
121
+ Land.usage
122
+ when "discard"
123
+ Land.usage(discard: true)
124
+ when "lanes"
125
+ LanesCommand.usage
126
+ when "mcp"
127
+ MCPCommand.usage
92
128
  when "orchestration"
93
129
  OrchestrationCommand.usage
94
130
  when "artifact-report"
@@ -102,7 +138,23 @@ module Harnex
102
138
 
103
139
  def usage
104
140
  <<~TEXT
105
- Usage:
141
+ harnex: Pi workers (GPT models) as tools for you and your agents.
142
+
143
+ Hand work to a worker (each runs in a visible tmux window):
144
+ harnex ask QUESTION A read-only answer from another model
145
+ harnex delegate [--worktree NAME] TASK
146
+ A worker does the task; prints its reply
147
+ harnex delegate --no-wait ... Start lanes in parallel, then:
148
+ harnex result [NAME...] Wait for lanes and print their results
149
+ harnex reply NAME MESSAGE Follow up; the worker keeps its context
150
+ harnex land [NAME...] Merge worktree lanes, remove the worktrees
151
+ harnex discard NAME Drop a lane and its worktree
152
+ harnex lanes Your lanes and what each needs next
153
+
154
+ Blocking commands wait 9m by default and exit 124 if the worker is still
155
+ going; run `harnex result NAME` to keep waiting. Exit 0 done, 1 failed.
156
+
157
+ Low-level session control:
106
158
  harnex run <cli> [options] [--] [cli-args...]
107
159
  harnex send --id ID [options] [text...]
108
160
  harnex wait --id ID [options]
@@ -113,61 +165,27 @@ module Harnex
113
165
  harnex events --id ID [options]
114
166
  harnex history [options]
115
167
  harnex pane --id ID [options]
116
- harnex agents-guide [topic]
117
- harnex doctor
168
+
169
+ The same verbs as native MCP tools (Claude Code, Pi):
170
+ harnex mcp install Register the harnex MCP server
171
+ harnex mcp Serve MCP over stdio (clients run this)
172
+
173
+ Setup, docs and telemetry:
174
+ harnex doctor Check Pi and harnex setup
175
+ harnex preflight ADAPTER [options] Prove an adapter with one tiny dispatch
176
+ harnex agents-guide [topic] Agent guides (dispatch, chain, buddy, monitoring, naming, claude)
177
+ harnex guide | recipes Getting started; workflow recipes
118
178
  harnex orchestration sample|report [options]
119
179
  harnex artifact-report init|validate PATH [options]
120
180
  harnex telemetry assert-canonical|reconcile [options]
121
181
  harnex help [command]
122
182
 
123
- Commands:
124
- run Start a wrapped interactive session and local API
125
- send Send text to an active session
126
- wait Block until a session exits or reaches a state
127
- watch Safely watch existing work until done/task_failed/timeout
128
- stop Send the adapter stop sequence to a session
129
- status List live sessions
130
- logs Read session output transcripts
131
- events Stream per-session JSONL runtime events
132
- history List completed dispatches from .harnex/dispatch.jsonl
133
- pane Capture the current tmux pane for a live session
134
- recipes List and read workflow recipes
135
- guide Show the getting started guide
136
- agents-guide
137
- Show agent dispatch, chain, buddy, monitoring, and naming guides
138
- doctor Run preflight checks and optional read-only session sweep
139
- orchestration
140
- Emit external primary samples and report orchestration tax
141
- artifact-report
142
- Validate harness-authored receipts or initialize legacy v1 documents
143
- telemetry
144
- Assert or reconcile canonical dispatch telemetry
145
- help Show command help
146
-
147
- New to harnex? Start with: harnex guide
148
- Working with agents (dispatching tasks)? Read: harnex agents-guide
149
-
150
- Notes:
151
- CLIs with smart prompt detection: #{Adapters.known.join(', ')}
152
- Any other CLI name is launched with generic wrapping.
153
-
154
183
  Examples:
155
- harnex run pi
156
- harnex run aider --id blue-cat
157
- harnex run codex -- --cd /path/to/repo
158
- harnex status
159
- harnex watch --id main --until done --max-wait 15m
160
- harnex logs --id main --follow
161
- harnex events --id main --snapshot
162
- harnex history --limit 20
163
- harnex pane --id main --lines 40
164
- harnex agents-guide dispatch
165
- harnex doctor
166
- harnex orchestration report --dispatch .harnex/dispatch.jsonl --run-id queue-005 --json
167
- harnex artifact-report validate /path/from-dispatch-row.json --final
168
- harnex artifact-report init .harnex/reports/legacy-v1.json
169
- harnex telemetry assert-canonical --canonical .harnex/dispatch.jsonl --json
170
- harnex send --id main --message "Summarize current progress."
184
+ harnex ask "Where is the dispatch row written, and is that path tested?"
185
+ harnex delegate --worktree word-count "Add TextKit.word_count with a test"
186
+ harnex land word-count
187
+ harnex delegate --no-wait --worktree fix-a "Fix A"; harnex delegate --no-wait --worktree fix-b "Fix B"; harnex result
188
+ harnex run pi --tmux pi-i-42 --id pi-i-42 --context-file /tmp/task-42.md
171
189
  TEXT
172
190
  end
173
191
  end
@@ -0,0 +1,278 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "open3"
4
+ require "securerandom"
5
+ require "shellwords"
6
+
7
+ module Harnex
8
+ # `harnex ask` (#85): hand one question to a Pi worker (a GPT or other
9
+ # provider model) and print its answer, so a Claude session consults
10
+ # another model the way it calls any tool. Read-only by default; the
11
+ # dispatch is a normal harnex run (tmux window, receipt, dispatch rows).
12
+ class Ask
13
+ READ_ONLY_TOOLS = "read,grep,find,ls".freeze
14
+ INTENT = "ask".freeze
15
+
16
+ def self.usage
17
+ <<~TEXT
18
+ Usage: harnex ask [options] [--] QUESTION...
19
+ harnex ask --file PATH [--section TEXT] [options]
20
+ harnex ask --resume ID [--timeout DUR] [--json]
21
+
22
+ Ask a Pi worker one question and print its answer on stdout. The worker
23
+ runs in a visible tmux window, reads the repository (it cannot run
24
+ commands unless --write; to run things use `harnex delegate`), and stops
25
+ when it has answered. stderr gets one line naming the lane, its time and model.
26
+ A read-only ask records its dispatch in the global harnex stream, so the
27
+ repository stays untouched; --write records in the repository.
28
+
29
+ Options:
30
+ --model NAME Pi model (provider/model); default: Pi's own default
31
+ (`defaultProvider`/`defaultModel` in ~/.pi/agent/settings.json)
32
+ --fallback-model NAME
33
+ Ask once more with NAME when the first model fails on
34
+ provider capacity or rate limits (provider_capacity)
35
+ --effort LEVEL Reasoning effort / Pi thinking level
36
+ --cwd DIR Directory the worker works in (default: current directory)
37
+ --file PATH Read the question from PATH (see run --context-file)
38
+ --section TEXT With --file: send only that section
39
+ --write Let the worker edit files and run commands (default:
40
+ read-only tools #{READ_ONLY_TOOLS})
41
+ --id ID Session id (default: pi-ask-<random>)
42
+ --timeout DUR Cap the wait: 540, 9m or 8m30s (default 9m; `none` waits
43
+ without a cap). On 124 the worker keeps going and stderr
44
+ names the command that keeps waiting (`harnex result ID`)
45
+ --resume ID Same as `harnex result ID` (kept for older callers)
46
+ --detach Run headless instead of in a tmux window
47
+ --json Print the full result JSON instead of the answer
48
+ -h, --help Show this help
49
+
50
+ Exit codes: 0 answered, 1 the worker failed (stderr says why), 2 proof
51
+ rejected or a usage error (usage errors print no JSON and no answer),
52
+ 124 timeout (the worker keeps running).
53
+
54
+ Examples:
55
+ harnex ask "Which module parses the dispatch rows, and is it tested?"
56
+ harnex ask --model openai/gpt-5.1 --file /tmp/review.md --timeout 9m
57
+ harnex ask --write --cwd ../lane "Fix the failing test and commit"
58
+ TEXT
59
+ end
60
+
61
+ def initialize(argv, out: $stdout, err: $stderr, harnex_command: nil)
62
+ @argv = argv.dup
63
+ @out = out
64
+ @err = err
65
+ @harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
66
+ @options = { model: nil, fallback_model: nil, effort: nil, cwd: nil, file: nil, section: nil, write: false,
67
+ id: nil, timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false, resume: nil,
68
+ repo: nil }
69
+ end
70
+
71
+ def run
72
+ question = parse
73
+ if @options[:help]
74
+ @out.puts self.class.usage
75
+ return 0
76
+ end
77
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ return report(*resume_once(@options[:resume]), Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) if @options[:resume]
79
+
80
+ if question.empty? && @options[:file].nil?
81
+ raise OptionParser::MissingArgument, "QUESTION (or --file PATH)"
82
+ end
83
+
84
+ result, exit_code = ask_once(question, @options[:model], @options[:id])
85
+ if exit_code == 1 && @options[:fallback_model] && result.dig("turn_result", "outcome_class") == "provider_capacity"
86
+ @err.puts("harnex ask: #{result['id']} hit provider capacity; asking #{@options[:fallback_model]} instead")
87
+ retry_id = @options[:id] && "#{@options[:id]}-fallback"
88
+ result, exit_code = ask_once(question, @options[:fallback_model], retry_id)
89
+ end
90
+ report(result, exit_code, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
91
+ end
92
+
93
+ private
94
+
95
+ # Each ask is a lane (#87), so `harnex result ID` keeps waiting on it and
96
+ # `harnex reply ID "..."` follows up with the worker's context.
97
+ def ask_once(question, model, id)
98
+ id ||= "pi-ask-#{SecureRandom.hex(2)}"
99
+ lane = register_lane(id, model)
100
+ stdout, status = launch(question, model, id)
101
+ result = parse_result(stdout)
102
+ Lanes.settle(lane, result["turn_result"] || {}, status.exitstatus) unless result.empty?
103
+ [result, status.exitstatus]
104
+ end
105
+
106
+ def register_lane(id, model)
107
+ cwd = File.expand_path(@options[:cwd] || Dir.pwd)
108
+ Lanes.save(
109
+ "name" => id, "kind" => "ask", "repo_root" => Lanes.main_root(cwd) || cwd, "run_root" => run_root,
110
+ "cwd" => cwd, "read_only" => !@options[:write], "owner" => Harnex.ambient_owner_id,
111
+ "requested_model" => model, "effort" => @options[:effort], "created_at" => Lanes.now,
112
+ "launched_at" => Lanes.now, "state" => "running", "turns" => 0
113
+ )
114
+ end
115
+
116
+ def launch(question, model, id)
117
+ argv = @harnex_command + ["run", "pi", "--id", id]
118
+ argv += @options[:detach] ? ["--detach"] : ["--tmux", id]
119
+ argv += ["--cwd", File.expand_path(@options[:cwd] || Dir.pwd)]
120
+ # A consult is not repository work: register and record it under a
121
+ # harnex-owned root (not a git repo, so rows go to the global stream).
122
+ argv += ["--root", ask_root] unless @options[:write]
123
+ if @options[:file]
124
+ argv += ["--context-file", @options[:file]]
125
+ argv += ["--context-section", @options[:section]] if @options[:section]
126
+ else
127
+ argv += ["--context", question]
128
+ end
129
+ argv += ["--auto-stop", "--wait-done", "--intent", INTENT]
130
+ argv += ["--wait-timeout", @options[:timeout].to_s] if @options[:timeout]
131
+ argv += ["--model", model] if model
132
+ argv += ["--effort", @options[:effort]] if @options[:effort]
133
+ argv += ["--", "--no-approve"]
134
+ argv += ["--tools", READ_ONLY_TOOLS] unless @options[:write]
135
+ stdout, status = Open3.capture2(*argv)
136
+ [stdout, status]
137
+ end
138
+
139
+ # A subagent's Bash call is capped at 10 minutes, so a long ask comes back
140
+ # 124 and the caller re-runs this until the answer lands. `wait` prints the
141
+ # turn result unnested; nest it so the answer is reported like a fresh ask.
142
+ def resume_once(id)
143
+ lane = begin
144
+ Lanes.resolve(id)
145
+ rescue OptionParser::InvalidArgument
146
+ nil
147
+ end
148
+ return legacy_resume(id) unless lane
149
+ return [{ "id" => id, "turn_result" => lane["last_turn"] || {} }, lane["state"] == "failed" ? 1 : 0] unless lane["state"] == "running"
150
+
151
+ turn, exit_code = Lanes.wait(lane, @harnex_command, timeout: @options[:timeout])
152
+ Lanes.settle(lane, turn, exit_code || 1)
153
+ [{ "id" => id, "turn_result" => turn }, exit_code || 1]
154
+ end
155
+
156
+ # Asks from before lanes (0.21.0) have no record; their resume line named --repo.
157
+ def legacy_resume(id)
158
+ raise OptionParser::InvalidArgument, "no lane named #{id.inspect}" unless @options[:repo]
159
+
160
+ argv = @harnex_command + ["wait", "--id", id, "--repo", @options[:repo], "--until", "done"]
161
+ argv += ["--timeout", @options[:timeout].to_s] if @options[:timeout]
162
+ stdout, status = Open3.capture2(*argv)
163
+ [{ "id" => id, "turn_result" => parse_result(stdout) }, status.exitstatus]
164
+ end
165
+
166
+ def resume_command(id)
167
+ "#{Harnex.cli_command} result #{id}"
168
+ end
169
+
170
+ # The root `run` records under: ask_root for a consult, the work
171
+ # directory for --write (run takes --cwd as the root when --root is absent).
172
+ def run_root
173
+ return @options[:repo] if @options[:resume]
174
+
175
+ @options[:write] ? File.expand_path(@options[:cwd] || Dir.pwd) : ask_root
176
+ end
177
+
178
+ def parse_result(stdout)
179
+ JSON.parse(stdout.to_s.lines.last.to_s)
180
+ rescue JSON::ParserError
181
+ {}
182
+ end
183
+
184
+ def ask_root
185
+ root = File.join(Harnex::STATE_DIR, "ask")
186
+ FileUtils.mkdir_p(root)
187
+ root
188
+ end
189
+
190
+ def report(result, exit_code, elapsed)
191
+ if @options[:json]
192
+ @out.puts JSON.generate(result) unless result.empty?
193
+ return exit_code
194
+ end
195
+
196
+ turn = result["turn_result"] || {}
197
+ answer = full_answer(turn)
198
+ case exit_code
199
+ when 0
200
+ @out.puts answer.to_s
201
+ @err.puts("harnex ask: #{result['id']} answered in #{elapsed.round}s (model #{turn['model'] || 'Pi default'})")
202
+ when Waiter::DONE_EXIT_TIMEOUT
203
+ @err.puts("harnex ask: #{result['id']} is still working; keep waiting with: #{resume_command(result['id'])}")
204
+ else
205
+ @out.puts answer unless answer.to_s.empty?
206
+ reason = turn["last_error"] || turn["outcome_class"] || turn["status"] || result["error"] || "no result"
207
+ @err.puts("harnex ask: #{result['id'] || 'the worker'} did not answer (#{reason})")
208
+ end
209
+ exit_code
210
+ end
211
+
212
+ def full_answer(turn)
213
+ path = turn["final_message_path"].to_s
214
+ return File.read(path).chomp if turn["final_message_truncated"] && !path.empty? && File.file?(path)
215
+
216
+ turn["final_message"]
217
+ rescue StandardError
218
+ turn["final_message"]
219
+ end
220
+
221
+ def parse
222
+ question = []
223
+ index = 0
224
+ while index < @argv.length
225
+ arg = @argv[index]
226
+ case arg
227
+ when "--"
228
+ question.concat(@argv[(index + 1)..] || [])
229
+ break
230
+ when "-h", "--help" then @options[:help] = true
231
+ when "--write" then @options[:write] = true
232
+ when "--detach" then @options[:detach] = true
233
+ when "--json" then @options[:json] = true
234
+ when "--model", "--effort", "--cwd", "--file", "--section", "--id", "--fallback-model", "--resume", "--repo"
235
+ @options[arg.delete_prefix("--").tr("-", "_").to_sym] = value(index += 1, arg)
236
+ # Habits from `harnex run` (#87): an ask already runs in a tmux window
237
+ # named after its id and stops after answering.
238
+ when "--tmux"
239
+ following = @argv[index + 1]
240
+ if following&.match?(Lanes::NAME) && !following.start_with?("-") && @argv.length > index + 2
241
+ @options[:id] ||= following
242
+ index += 1
243
+ end
244
+ when "--auto-stop", "--wait-done" then nil
245
+ when "--wait-timeout"
246
+ @options[:timeout] = Lanes.parse_timeout(value(index += 1, arg), option_name: arg)
247
+ when "--context" then question << value(index += 1, arg)
248
+ when "--context-file" then @options[:file] = value(index += 1, arg)
249
+ when "--timeout"
250
+ @options[:timeout] = Lanes.parse_timeout(value(index += 1, arg), option_name: arg)
251
+ when /\A--/
252
+ raise OptionParser::InvalidOption, "harnex ask: unknown flag #{arg}; see harnex ask --help"
253
+ else
254
+ question << arg
255
+ end
256
+ index += 1
257
+ end
258
+ if @options[:section] && @options[:file].nil?
259
+ raise OptionParser::InvalidOption, "harnex ask: --section requires --file"
260
+ end
261
+ if @options[:resume] && (!question.empty? || @options[:file])
262
+ raise OptionParser::InvalidOption, "harnex ask: --resume takes a lane id and no question"
263
+ end
264
+ if @options[:repo] && @options[:resume].nil?
265
+ raise OptionParser::InvalidOption, "harnex ask: --repo requires --resume"
266
+ end
267
+
268
+ question.join(" ")
269
+ end
270
+
271
+ def value(index, name)
272
+ value = @argv[index]
273
+ raise OptionParser::MissingArgument, name if value.nil? || value.start_with?("--")
274
+
275
+ value
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,202 @@
1
+ module Harnex
2
+ # `harnex delegate` (#87): hand one task to a Pi worker and print its
3
+ # reply plus a lane footer (state, time, model, cost, commits, next
4
+ # commands). With --worktree NAME the worker gets its own git worktree on
5
+ # branch harnex/NAME, so parallel lanes never touch the main checkout.
6
+ class Delegate
7
+ def self.usage
8
+ <<~TEXT
9
+ Usage: harnex delegate [options] [--] TASK...
10
+ harnex delegate --file PATH [options]
11
+
12
+ Hand a task to a Pi worker (a GPT model by default) and print its reply
13
+ and a lane summary. The worker runs in a visible tmux window, may edit
14
+ files and run commands, and stops when it has replied. Follow up with
15
+ `harnex reply NAME "..."`; it keeps the worker's context.
16
+
17
+ Options:
18
+ --worktree NAME Work in a new git worktree (.harnex/worktrees/NAME, branch
19
+ harnex/NAME); finish with `harnex land NAME` or
20
+ `harnex discard NAME`
21
+ --name NAME Lane name without a worktree (default: from the task)
22
+ --cwd DIR Directory the worker works in (default: current directory)
23
+ --file PATH Read the task from PATH
24
+ --model NAME Pi model (provider/model); default: Pi's own default
25
+ --effort LEVEL Reasoning effort / Pi thinking level
26
+ --no-wait Start the lane and return at once; collect with
27
+ `harnex result` (all your lanes) or `harnex result NAME`
28
+ --timeout DUR Wait at most DUR (default 9m, fits a 10-minute tool cap;
29
+ `none` waits without a cap). On 124 the worker keeps going:
30
+ run `harnex result NAME`
31
+ --detach Run headless instead of in a tmux window
32
+ --json Print the lane record and result as JSON
33
+ -h, --help Show this help
34
+
35
+ Exit codes: 0 done, 1 failed, 2 usage error, 124 still working.
36
+
37
+ Examples:
38
+ harnex delegate --worktree word-count "Add TextKit.word_count with a test"
39
+ harnex delegate --no-wait --worktree fix-a "Fix A" # then: harnex result
40
+ harnex delegate "Run bin/slow-bench and report the score"
41
+ TEXT
42
+ end
43
+
44
+ def initialize(argv, out: $stdout, err: $stderr, harnex_command: nil)
45
+ @argv = argv.dup
46
+ @out = out
47
+ @err = err
48
+ @harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
49
+ @options = { worktree: nil, name: nil, cwd: nil, file: nil, model: nil, effort: nil, wait: true,
50
+ timeout: Lanes::DEFAULT_TIMEOUT, detach: false, json: false, help: false }
51
+ end
52
+
53
+ def run
54
+ task = parse
55
+ if @options[:help]
56
+ @out.puts self.class.usage
57
+ return 0
58
+ end
59
+ task = File.read(@options[:file]) if task.empty? && @options[:file]
60
+ raise OptionParser::MissingArgument, "TASK (or --file PATH)" if task.strip.empty?
61
+
62
+ lane = @lane = build_lane(task)
63
+ brief = Lanes.write_brief(lane, task)
64
+ turn, exit_code = Lanes.launch(lane, @harnex_command, context_args: ["--context-file", brief],
65
+ wait: @options[:wait], timeout: @options[:timeout],
66
+ detach: @options[:detach])
67
+ return started(lane) unless @options[:wait]
68
+
69
+ lane = Lanes.settle(lane, turn, exit_code)
70
+ report(lane, turn, exit_code)
71
+ rescue Lanes::LaunchError => e
72
+ discard_unstarted
73
+ @err.puts(e.message)
74
+ 1
75
+ end
76
+
77
+ private
78
+
79
+ def build_lane(task)
80
+ workdir = File.expand_path(@options[:cwd] || Dir.pwd)
81
+ raise OptionParser::InvalidArgument, "--cwd #{workdir} is not a directory" unless File.directory?(workdir)
82
+
83
+ root = Lanes.main_root(workdir)
84
+ raise OptionParser::InvalidArgument, "--worktree needs a git repository (#{workdir} is not in one)" if @options[:worktree] && root.nil?
85
+
86
+ Lanes.sweep_merged(root)
87
+ root ||= workdir
88
+ name = Lanes.validate_name!(@options[:worktree] || @options[:name] || Lanes.generate_name(root, task))
89
+ ensure_free!(root, name)
90
+ lane = {
91
+ "name" => name, "kind" => "delegate", "repo_root" => root, "run_root" => root, "cwd" => workdir,
92
+ "owner" => Harnex.ambient_owner_id, "requested_model" => @options[:model], "effort" => @options[:effort],
93
+ "created_at" => Lanes.now, "turns" => 0
94
+ }
95
+ if @options[:worktree]
96
+ warn_uncommitted(root)
97
+ lane["worktree"], lane["branch"], lane["base_sha"] = Lanes.create_worktree(root, name)
98
+ lane["cwd"] = lane["worktree"]
99
+ else
100
+ Harnex.ensure_git_excluded(root) if Lanes.main_root(workdir)
101
+ lane["base_sha"] = Lanes.head_sha(workdir)
102
+ end
103
+ lane
104
+ end
105
+
106
+ # A lane that never started leaves nothing behind.
107
+ def discard_unstarted
108
+ return unless @lane && @lane["worktree"] && @lane["turns"].to_i.zero?
109
+
110
+ Lanes.remove_worktree(@lane, force: true)
111
+ @lane["state"] = "discarded"
112
+ Lanes.save(@lane)
113
+ end
114
+
115
+ def ensure_free!(root, name)
116
+ existing = Lanes.load(root, name)
117
+ if Lanes.open?(existing)
118
+ raise OptionParser::InvalidArgument,
119
+ "lane #{name} already exists (#{existing['state']}): follow up with `harnex reply #{name} \"...\"`, " \
120
+ "or `harnex land #{name}` / `harnex discard #{name}` first"
121
+ end
122
+ return unless Harnex.read_registry(root, name)
123
+
124
+ raise OptionParser::InvalidArgument, "a harnex session named #{name} is running; pick another name"
125
+ end
126
+
127
+ def warn_uncommitted(root)
128
+ dirty = Lanes.dirty_paths(root)
129
+ return if dirty.empty?
130
+
131
+ @err.puts("harnex delegate: the main checkout has #{dirty.length} uncommitted change(s); " \
132
+ "the worktree starts from HEAD and won't see them")
133
+ end
134
+
135
+ def started(lane)
136
+ if @options[:json]
137
+ @out.puts JSON.generate(lane)
138
+ else
139
+ where = lane["worktree"] ? " in #{Lanes.relative(lane['repo_root'], lane['worktree'])}" : ""
140
+ @out.puts "── #{lane['name']}: started#{where}"
141
+ @out.puts " collect: harnex result #{lane['name']} (or `harnex result` for all your lanes)"
142
+ end
143
+ 0
144
+ end
145
+
146
+ def report(lane, turn, exit_code)
147
+ if @options[:json]
148
+ @out.puts JSON.generate(lane: lane, result: turn, exit_code: exit_code)
149
+ else
150
+ @out.puts Lanes.render(lane, turn, exit_code)
151
+ end
152
+ exit_code
153
+ end
154
+
155
+ def parse
156
+ task = []
157
+ index = 0
158
+ while index < @argv.length
159
+ arg = @argv[index]
160
+ case arg
161
+ when "--"
162
+ task.concat(@argv[(index + 1)..] || [])
163
+ break
164
+ when "-h", "--help" then @options[:help] = true
165
+ when "--no-wait" then @options[:wait] = false
166
+ when "--detach" then @options[:detach] = true
167
+ when "--json" then @options[:json] = true
168
+ when "--worktree", "--name", "--cwd", "--file", "--model", "--effort"
169
+ @options[arg.delete_prefix("--").to_sym] = value(index += 1, arg)
170
+ when "--timeout", "--wait-timeout"
171
+ @options[:timeout] = Lanes.parse_timeout(value(index += 1, arg))
172
+ # Habits from `harnex run`: a lane already runs in a tmux window named
173
+ # after it, stops after its reply and waits for it, so these are
174
+ # accepted rather than refused.
175
+ when "--id" then @options[:name] = value(index += 1, arg)
176
+ when "--tmux" then index += 1 if lane_name_follows?(index) && (@options[:name] ||= @argv[index + 1])
177
+ when "--auto-stop", "--wait-done" then nil
178
+ when "--context" then task << value(index += 1, arg)
179
+ when "--context-file" then @options[:file] = value(index += 1, arg)
180
+ when /\A--/
181
+ raise OptionParser::InvalidOption, "harnex delegate: unknown flag #{arg}; see harnex delegate --help"
182
+ else
183
+ task << arg
184
+ end
185
+ index += 1
186
+ end
187
+ task.join(" ")
188
+ end
189
+
190
+ def lane_name_follows?(index)
191
+ following = @argv[index + 1]
192
+ !following.nil? && following.match?(Lanes::NAME) && !following.start_with?("-")
193
+ end
194
+
195
+ def value(index, name)
196
+ value = @argv[index]
197
+ raise OptionParser::MissingArgument, name if value.nil? || value.start_with?("--")
198
+
199
+ value
200
+ end
201
+ end
202
+ end