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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +592 -0
- data/GUIDE.md +222 -68
- data/README.md +78 -48
- data/TECHNICAL.md +235 -98
- data/agents/gpt.md +60 -0
- data/docs/agents.md +119 -0
- data/docs/dispatch-telemetry.md +184 -50
- data/docs/events.md +20 -7
- data/docs/lanes.md +202 -0
- data/docs/mcp.md +102 -0
- data/docs/pi-rpc.md +81 -7
- data/docs/upgrading-to-0.29.md +87 -0
- data/guides/01_dispatch.md +101 -34
- data/guides/04_monitoring.md +223 -25
- data/guides/05_naming.md +0 -1
- data/guides/06_claude.md +96 -0
- data/lib/harnex/adapters/base.rb +4 -8
- data/lib/harnex/adapters/pi.rb +92 -12
- data/lib/harnex/adapters.rb +1 -16
- data/lib/harnex/artifact_report.rb +206 -12
- data/lib/harnex/cli.rb +68 -50
- data/lib/harnex/commands/ask.rb +278 -0
- data/lib/harnex/commands/delegate.rb +202 -0
- data/lib/harnex/commands/doctor.rb +34 -36
- data/lib/harnex/commands/events.rb +3 -3
- data/lib/harnex/commands/history.rb +2 -8
- data/lib/harnex/commands/land.rb +150 -0
- data/lib/harnex/commands/lanes.rb +84 -0
- data/lib/harnex/commands/logs.rb +3 -3
- data/lib/harnex/commands/mcp.rb +208 -0
- data/lib/harnex/commands/orchestration.rb +88 -9
- data/lib/harnex/commands/pane.rb +3 -3
- data/lib/harnex/commands/preflight.rb +566 -0
- data/lib/harnex/commands/reply.rb +125 -0
- data/lib/harnex/commands/result.rb +108 -0
- data/lib/harnex/commands/run.rb +248 -64
- data/lib/harnex/commands/send.rb +121 -16
- data/lib/harnex/commands/status.rb +32 -4
- data/lib/harnex/commands/stop.rb +14 -4
- data/lib/harnex/commands/telemetry.rb +19 -1
- data/lib/harnex/commands/wait.rb +421 -56
- data/lib/harnex/commands/watch.rb +134 -21
- data/lib/harnex/context_file.rb +85 -0
- data/lib/harnex/core.rb +100 -15
- data/lib/harnex/dispatch_history.rb +157 -10
- data/lib/harnex/lanes.rb +513 -0
- data/lib/harnex/mcp.rb +362 -0
- data/lib/harnex/orchestration.rb +128 -5
- data/lib/harnex/owned_process_group.rb +50 -0
- data/lib/harnex/owner_stream.rb +399 -0
- data/lib/harnex/pricing.rb +4 -9
- data/lib/harnex/runtime/activity_tracker.rb +107 -0
- data/lib/harnex/runtime/api_server.rb +11 -1
- data/lib/harnex/runtime/completion_notifier.rb +90 -11
- data/lib/harnex/runtime/inbox.rb +26 -7
- data/lib/harnex/runtime/message.rb +5 -2
- data/lib/harnex/runtime/runtime_budget.rb +96 -0
- data/lib/harnex/runtime/session.rb +684 -294
- data/lib/harnex/runtime/stop_request.rb +39 -0
- data/lib/harnex/stall.rb +46 -0
- data/lib/harnex/telemetry_reconciler.rb +42 -20
- data/lib/harnex/terminal_status.rb +5 -5
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +16 -0
- data/recipes/01_fire_and_watch.md +14 -14
- data/recipes/02_chain_implement.md +35 -31
- data/recipes/03_buddy.md +1 -1
- metadata +34 -12
- data/docs/codex-appserver.md +0 -175
- data/lib/harnex/adapters/codex.rb +0 -160
- data/lib/harnex/adapters/codex_appserver.rb +0 -407
- data/lib/harnex/codex/app_server/client.rb +0 -347
|
@@ -3,21 +3,18 @@ require "optparse"
|
|
|
3
3
|
|
|
4
4
|
module Harnex
|
|
5
5
|
class Doctor
|
|
6
|
-
MIN_CODEX_VERSION = Gem::Version.new("0.128.0")
|
|
7
6
|
MIN_PI_VERSION = Harnex::Adapters::Pi::MIN_RPC_VERSION
|
|
8
|
-
SUPPORTED_ADAPTERS = %w[
|
|
7
|
+
SUPPORTED_ADAPTERS = %w[pi].freeze
|
|
9
8
|
|
|
10
9
|
def self.usage
|
|
11
10
|
<<~TEXT
|
|
12
|
-
Usage: harnex doctor [--adapter
|
|
11
|
+
Usage: harnex doctor [--adapter pi|all] [--sweep] [--prune [--dry-run]]
|
|
13
12
|
|
|
14
|
-
Runs static preflight checks for harnex adapter dependencies.
|
|
15
|
-
checked by default for backwards compatibility; select Pi explicitly
|
|
16
|
-
after installing or upgrading it.
|
|
13
|
+
Runs static preflight checks for harnex adapter dependencies (Pi).
|
|
17
14
|
|
|
18
15
|
Options:
|
|
19
16
|
--adapter NAME
|
|
20
|
-
Check
|
|
17
|
+
Check pi or all (default: pi)
|
|
21
18
|
--sweep Include a read-only report of harnex/tmux session drift
|
|
22
19
|
--prune Apply bounded harnex events/output/receipt retention pruning
|
|
23
20
|
--dry-run Preview --prune candidates without deleting
|
|
@@ -34,6 +31,9 @@ module Harnex
|
|
|
34
31
|
Gotchas:
|
|
35
32
|
doctor validates local adapter prerequisites; it does not start sessions.
|
|
36
33
|
Pi RPC requires >= #{MIN_PI_VERSION} so `agent_settled` is available.
|
|
34
|
+
JSON includes harnex_version and versioned capabilities; check
|
|
35
|
+
capabilities.runtime_budget == 1 before using run --max-runtime, and
|
|
36
|
+
capabilities.pi_command_proof == 1 before trusting Pi receipt commands.
|
|
37
37
|
--sweep is diagnostic only; it does not stop sessions or remove files.
|
|
38
38
|
--dry-run must be paired with --prune.
|
|
39
39
|
Run it after installing or upgrading a selected agent CLI.
|
|
@@ -59,12 +59,32 @@ module Harnex
|
|
|
59
59
|
return 0
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
checks = selected_adapters.map { |
|
|
62
|
+
checks = selected_adapters.map { |_name| check_pi }
|
|
63
63
|
retention = retention_payload
|
|
64
64
|
summary = {
|
|
65
65
|
ok: checks.all? { |c| c[:ok] } && retention.fetch(:ok, true),
|
|
66
66
|
checks: checks,
|
|
67
|
-
retention: retention
|
|
67
|
+
retention: retention,
|
|
68
|
+
harnex_version: Harnex::VERSION,
|
|
69
|
+
integrations: MCPCommand.integrations,
|
|
70
|
+
capabilities: {
|
|
71
|
+
runtime_budget: 1,
|
|
72
|
+
work_activity: 1,
|
|
73
|
+
stop_provenance: 1,
|
|
74
|
+
monitor_deadline: 1,
|
|
75
|
+
monitor_heartbeat: 1,
|
|
76
|
+
pi_idle_stop: 1,
|
|
77
|
+
pi_command_proof: 1,
|
|
78
|
+
final_gates: 1,
|
|
79
|
+
outcome_classes: 1,
|
|
80
|
+
preflight: 1,
|
|
81
|
+
ask: 1,
|
|
82
|
+
final_message: 1,
|
|
83
|
+
owner_tag: 1,
|
|
84
|
+
ask_resume: 1,
|
|
85
|
+
lanes: 1,
|
|
86
|
+
mcp: 1
|
|
87
|
+
}
|
|
68
88
|
}
|
|
69
89
|
summary[:sweep] = sweep_payload if @options[:sweep]
|
|
70
90
|
puts JSON.generate(summary)
|
|
@@ -75,8 +95,8 @@ module Harnex
|
|
|
75
95
|
|
|
76
96
|
def parser
|
|
77
97
|
@parser ||= OptionParser.new do |opts|
|
|
78
|
-
opts.banner = "Usage: harnex doctor [--adapter
|
|
79
|
-
opts.on("--adapter NAME", "Check
|
|
98
|
+
opts.banner = "Usage: harnex doctor [--adapter pi|all] [--sweep] [--prune [--dry-run]]"
|
|
99
|
+
opts.on("--adapter NAME", "Check pi or all") { |value| @options[:adapters] << value.to_s.downcase }
|
|
80
100
|
opts.on("--sweep", "Include read-only session drift diagnostics") { @options[:sweep] = true }
|
|
81
101
|
opts.on("--prune", "Apply retention pruning") { @options[:prune] = true }
|
|
82
102
|
opts.on("--dry-run", "Preview --prune candidates without deleting") { @options[:dry_run] = true }
|
|
@@ -99,7 +119,7 @@ module Harnex
|
|
|
99
119
|
|
|
100
120
|
def selected_adapters
|
|
101
121
|
requested = @options[:adapters]
|
|
102
|
-
return ["
|
|
122
|
+
return ["pi"] if requested.empty?
|
|
103
123
|
return SUPPORTED_ADAPTERS if requested.include?("all")
|
|
104
124
|
|
|
105
125
|
requested.uniq
|
|
@@ -120,30 +140,6 @@ module Harnex
|
|
|
120
140
|
{ ok: false, error: e.message }
|
|
121
141
|
end
|
|
122
142
|
|
|
123
|
-
def check_codex
|
|
124
|
-
result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
|
|
125
|
-
|
|
126
|
-
version_output, status = capture("codex --version")
|
|
127
|
-
if status.nil?
|
|
128
|
-
return result.merge(ok: false, error: "codex CLI not found on PATH")
|
|
129
|
-
end
|
|
130
|
-
unless status.success?
|
|
131
|
-
return result.merge(ok: false, error: "codex --version failed: #{version_output.strip}")
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
version = parse_version(version_output)
|
|
135
|
-
if version.nil?
|
|
136
|
-
return result.merge(ok: false, found: version_output.strip, error: "could not parse codex version output")
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
if version < MIN_CODEX_VERSION
|
|
140
|
-
return result.merge(ok: false, found: version.to_s,
|
|
141
|
-
error: "codex #{version} < required #{MIN_CODEX_VERSION}; upgrade with `npm i -g @openai/codex` or your platform package manager")
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
result.merge(ok: true, found: version.to_s)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
143
|
def check_pi
|
|
148
144
|
result = { name: "pi", required: ">= #{MIN_PI_VERSION}" }
|
|
149
145
|
|
|
@@ -206,6 +202,8 @@ module Harnex
|
|
|
206
202
|
end
|
|
207
203
|
rescue JSON::ParserError
|
|
208
204
|
stale << { registry_path: path, error: "invalid_json" }
|
|
205
|
+
rescue Errno::ENOENT
|
|
206
|
+
next # removed by a finalizing session after the glob
|
|
209
207
|
end
|
|
210
208
|
|
|
211
209
|
[active.sort_by { |session| [session["repo_root"].to_s, session["started_at"].to_s, session["id"].to_s] }.reverse, stale]
|
|
@@ -20,9 +20,9 @@ module Harnex
|
|
|
20
20
|
-h, --help Show this help
|
|
21
21
|
|
|
22
22
|
Common patterns:
|
|
23
|
-
#{program_name} --id
|
|
24
|
-
#{program_name} --id
|
|
25
|
-
#{program_name} --id
|
|
23
|
+
#{program_name} --id pi-i-42 --snapshot
|
|
24
|
+
#{program_name} --id pi-i-42
|
|
25
|
+
#{program_name} --id pi-i-42 --from 2026-05-06T10:00:00Z --snapshot
|
|
26
26
|
|
|
27
27
|
Gotchas:
|
|
28
28
|
events is structured JSONL; logs is human transcript text.
|
|
@@ -135,15 +135,9 @@ module Harnex
|
|
|
135
135
|
|
|
136
136
|
def load_records
|
|
137
137
|
path = DispatchHistory.path_for(Dir.pwd, global: @options[:global])
|
|
138
|
-
return [] unless File.file?(path)
|
|
138
|
+
return [] unless File.file?(path) || !ENV.fetch(DispatchHistory::HISTORY_READER_ENV, "").strip.empty?
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
next if line.strip.empty?
|
|
142
|
-
|
|
143
|
-
JSON.parse(line)
|
|
144
|
-
rescue JSON::ParserError
|
|
145
|
-
nil
|
|
146
|
-
end
|
|
140
|
+
DispatchHistory.each_record(path).to_a
|
|
147
141
|
end
|
|
148
142
|
|
|
149
143
|
def started_after?(record, floor)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
# `harnex land` / `harnex discard` (#87): finish a worktree lane. Land
|
|
3
|
+
# merges harnex/NAME into the main checkout's current branch, then removes
|
|
4
|
+
# the worktree and branch; on conflict git's merge stays in progress for
|
|
5
|
+
# the caller to resolve, and the next `land` cleans up. Discard drops the
|
|
6
|
+
# lane's work.
|
|
7
|
+
class Land
|
|
8
|
+
def self.usage(discard: false)
|
|
9
|
+
if discard
|
|
10
|
+
<<~TEXT
|
|
11
|
+
Usage: harnex discard NAME...
|
|
12
|
+
|
|
13
|
+
Stop a lane (if it is still working) and delete its worktree and branch
|
|
14
|
+
without merging. Uncommitted and unmerged work in the lane is lost.
|
|
15
|
+
TEXT
|
|
16
|
+
else
|
|
17
|
+
<<~TEXT
|
|
18
|
+
Usage: harnex land [NAME...]
|
|
19
|
+
|
|
20
|
+
Merge a worktree lane (branch harnex/NAME) into the current branch of the
|
|
21
|
+
main checkout, then remove its worktree and branch. With no NAME: every
|
|
22
|
+
finished worktree lane you started in this repo, oldest first. Uncommitted changes
|
|
23
|
+
in the lane are committed first. On a merge conflict the merge stays in
|
|
24
|
+
progress: resolve it, `git commit`, and run `harnex land NAME` again to
|
|
25
|
+
clean up (or `git merge --abort`).
|
|
26
|
+
|
|
27
|
+
Exit codes: 0 landed, 1 conflict or another problem (the lane is kept).
|
|
28
|
+
TEXT
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(argv, out: $stdout, err: $stderr, discard: false, harnex_command: nil)
|
|
33
|
+
@argv = argv.dup
|
|
34
|
+
@out = out
|
|
35
|
+
@err = err
|
|
36
|
+
@discard = discard
|
|
37
|
+
@harnex_command = harnex_command || [Gem.ruby, File.expand_path("../../../bin/harnex", __dir__)]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
if (@argv.empty? && @discard) || @argv.any? { |arg| %w[-h --help].include?(arg) }
|
|
42
|
+
@out.puts self.class.usage(discard: @discard)
|
|
43
|
+
return @argv.empty? ? 2 : 0
|
|
44
|
+
end
|
|
45
|
+
flag = @argv.find { |arg| arg.start_with?("-") }
|
|
46
|
+
raise OptionParser::InvalidOption, "harnex #{verb}: unknown flag #{flag}" if flag
|
|
47
|
+
|
|
48
|
+
lanes = @argv.empty? ? finished_lanes : @argv.map { |name| Lanes.resolve(name) }
|
|
49
|
+
if lanes.empty?
|
|
50
|
+
@out.puts "no finished worktree lanes to land (see `harnex lanes`)"
|
|
51
|
+
return 0
|
|
52
|
+
end
|
|
53
|
+
lanes.map { |lane| @discard ? discard(lane) : land(lane) }.max
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# `harnex land` alone: the caller's finished worktree lanes in this repo.
|
|
59
|
+
def finished_lanes
|
|
60
|
+
root = Lanes.main_root(Dir.pwd)
|
|
61
|
+
owner = Harnex.ambient_owner_id
|
|
62
|
+
Lanes.all.select { |lane| lane["repo_root"] == root && lane["owner"] == owner && lane["branch"] && lane["state"] == "done" }
|
|
63
|
+
.sort_by { |lane| lane["created_at"].to_s }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verb
|
|
67
|
+
@discard ? "discard" : "land"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def land(lane)
|
|
71
|
+
name = lane["name"]
|
|
72
|
+
return say(lane, "already landed") if lane["state"] == "landed"
|
|
73
|
+
return refuse(lane, "it was discarded") if lane["state"] == "discarded"
|
|
74
|
+
return refuse(lane, "it worked in place (no worktree); its changes are already in #{lane['cwd']}") unless lane["branch"]
|
|
75
|
+
return refuse(lane, "it is still working; wait with `harnex result #{name}` or stop it with `harnex discard #{name}`") if Lanes.live?(lane)
|
|
76
|
+
|
|
77
|
+
root = lane["repo_root"]
|
|
78
|
+
if File.exist?(File.join(git_dir(root), "MERGE_HEAD"))
|
|
79
|
+
conflicts = conflicted(root)
|
|
80
|
+
return refuse(lane, "a merge is in progress in #{root}#{conflicts.empty? ? '' : " (conflicts: #{conflicts.join(', ')})"}; " \
|
|
81
|
+
"finish it with `git commit` (or `git merge --abort`), then run `harnex land #{name}` again")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
committed = commit_leftovers(lane)
|
|
85
|
+
target, = Lanes.git(root, "branch", "--show-current")
|
|
86
|
+
_out, _err, merged = Lanes.git(root, "merge-base", "--is-ancestor", lane["branch"], "HEAD")
|
|
87
|
+
unless merged
|
|
88
|
+
count, = Lanes.git(root, "rev-list", "--count", "HEAD..#{lane['branch']}")
|
|
89
|
+
_out, err, ok = Lanes.git(root, "merge", "--no-edit", lane["branch"])
|
|
90
|
+
unless ok
|
|
91
|
+
conflicts = conflicted(root)
|
|
92
|
+
return refuse(lane, "could not merge #{lane['branch']}: #{err.lines.last.to_s.strip}") if conflicts.empty?
|
|
93
|
+
|
|
94
|
+
return refuse(lane, "merge conflict in #{conflicts.join(', ')} (the merge is left in progress in #{root}).\n" \
|
|
95
|
+
" next: resolve the files, `git add` them and `git commit`, then `harnex land #{name}` again " \
|
|
96
|
+
"to clean up; to give up: `git merge --abort`")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
removed, err = Lanes.remove_worktree(lane)
|
|
101
|
+
return refuse(lane, "merged, but could not remove its worktree: #{err}") unless removed
|
|
102
|
+
|
|
103
|
+
lane["state"] = "landed"
|
|
104
|
+
lane["landed_at"] = Lanes.now
|
|
105
|
+
Lanes.save(lane)
|
|
106
|
+
what = merged ? "was already merged" : "merged #{lane['branch']} into #{target.empty? ? 'HEAD' : target} (#{count} commit#{count == '1' ? '' : 's'})"
|
|
107
|
+
say(lane, "#{what}#{committed ? '; committed its uncommitted changes first' : ''}; removed its worktree and branch")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def discard(lane)
|
|
111
|
+
return say(lane, "already #{lane['state']}") if Lanes::CLOSED_STATES.include?(lane["state"])
|
|
112
|
+
|
|
113
|
+
if Lanes.live?(lane)
|
|
114
|
+
Open3.capture3(*(@harnex_command + ["stop", "--id", lane["name"], "--repo", lane["run_root"]]))
|
|
115
|
+
end
|
|
116
|
+
Lanes.remove_worktree(lane, force: true) if lane["branch"]
|
|
117
|
+
lane["state"] = "discarded"
|
|
118
|
+
Lanes.save(lane)
|
|
119
|
+
say(lane, lane["branch"] ? "discarded; removed its worktree and branch #{lane['branch']}" : "discarded")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def commit_leftovers(lane)
|
|
123
|
+
return false unless lane["worktree"] && File.directory?(lane["worktree"]) && Lanes.dirty_paths(lane["worktree"]).any?
|
|
124
|
+
|
|
125
|
+
Lanes.git(lane["worktree"], "add", "-A")
|
|
126
|
+
_out, _err, ok = Lanes.git(lane["worktree"], "commit", "-q", "-m", "harnex: uncommitted changes from lane #{lane['name']}")
|
|
127
|
+
ok
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def git_dir(root)
|
|
131
|
+
dir, = Lanes.git(root, "rev-parse", "--absolute-git-dir")
|
|
132
|
+
dir
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def conflicted(root)
|
|
136
|
+
out, = Lanes.git(root, "diff", "--name-only", "--diff-filter=U")
|
|
137
|
+
out.lines.map(&:strip).reject(&:empty?)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def say(lane, text)
|
|
141
|
+
@out.puts "── #{lane['name']}: #{text}"
|
|
142
|
+
0
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def refuse(lane, text)
|
|
146
|
+
@out.puts "── #{lane['name']}: not landed — #{text}"
|
|
147
|
+
1
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
# `harnex lanes` (#87): the lanes in this repo (or everywhere), newest
|
|
3
|
+
# first, with what each needs next.
|
|
4
|
+
class LanesCommand
|
|
5
|
+
def self.usage
|
|
6
|
+
<<~TEXT
|
|
7
|
+
Usage: harnex lanes [--all] [--closed] [--json]
|
|
8
|
+
|
|
9
|
+
List the lanes of this repo: name, state, age, model, branch and what
|
|
10
|
+
to do next. Landed and discarded lanes are hidden unless --closed.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--all Lanes of every repo
|
|
14
|
+
--closed Include landed and discarded lanes
|
|
15
|
+
--json JSON array of lane records
|
|
16
|
+
-h, --help Show this help
|
|
17
|
+
TEXT
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(argv, out: $stdout)
|
|
21
|
+
@argv = argv.dup
|
|
22
|
+
@out = out
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
unknown = @argv - %w[--all --closed --json -h --help]
|
|
27
|
+
raise OptionParser::InvalidOption, "harnex lanes: unknown argument #{unknown.first}" unless unknown.empty?
|
|
28
|
+
|
|
29
|
+
if (@argv & %w[-h --help]).any?
|
|
30
|
+
@out.puts self.class.usage
|
|
31
|
+
return 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Lanes.sweep_merged(Lanes.main_root(Dir.pwd))
|
|
35
|
+
lanes = selected
|
|
36
|
+
if @argv.include?("--json")
|
|
37
|
+
@out.puts JSON.generate(lanes.map { |lane| lane.merge("live" => Lanes.live?(lane)) })
|
|
38
|
+
elsif lanes.empty?
|
|
39
|
+
@out.puts "no lanes#{@argv.include?('--all') ? '' : ' in this repo'} (start one with `harnex delegate`)"
|
|
40
|
+
else
|
|
41
|
+
@out.puts table(lanes)
|
|
42
|
+
end
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def selected
|
|
49
|
+
root = Lanes.main_root(Dir.pwd) || File.expand_path(Dir.pwd)
|
|
50
|
+
lanes = Lanes.all
|
|
51
|
+
lanes = lanes.select { |lane| lane["repo_root"] == root } unless @argv.include?("--all")
|
|
52
|
+
lanes = lanes.select { |lane| Lanes.open?(lane) } unless @argv.include?("--closed")
|
|
53
|
+
lanes.sort_by { |lane| lane["launched_at"] || lane["created_at"] || "" }.reverse
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def table(lanes)
|
|
57
|
+
rows = lanes.map do |lane|
|
|
58
|
+
[lane["name"], state(lane), Lanes.duration(lane["launched_at"] || lane["created_at"]),
|
|
59
|
+
lane["model"] || lane["requested_model"] || "-", lane["branch"] || lane["kind"], next_step(lane)]
|
|
60
|
+
end
|
|
61
|
+
header = %w[NAME STATE AGE MODEL BRANCH NEXT]
|
|
62
|
+
widths = header.each_index.map { |i| ([header[i]] + rows.map { |r| r[i].to_s }).map(&:length).max }
|
|
63
|
+
([header] + rows).map { |row| row.each_with_index.map { |cell, i| i == row.length - 1 ? cell.to_s : cell.to_s.ljust(widths[i]) }.join(" ") }.join("\n")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def state(lane)
|
|
67
|
+
return lane["state"] unless lane["state"] == "running"
|
|
68
|
+
return "finished, uncollected" unless Lanes.live?(lane)
|
|
69
|
+
|
|
70
|
+
detail = Lanes.progress_line(lane)[/ · (now .*|thinking)\z/, 1]
|
|
71
|
+
detail ? "working (#{detail})" : "working"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def next_step(lane)
|
|
75
|
+
name = lane["name"]
|
|
76
|
+
case lane["state"]
|
|
77
|
+
when "running" then "harnex result #{name}"
|
|
78
|
+
when "done" then lane["branch"] ? "harnex land #{name}" : "harnex reply #{name} \"...\""
|
|
79
|
+
when "failed" then "harnex reply #{name} \"Continue...\""
|
|
80
|
+
else "-"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/harnex/commands/logs.rb
CHANGED
|
@@ -19,9 +19,9 @@ module Harnex
|
|
|
19
19
|
-h, --help Show this help
|
|
20
20
|
|
|
21
21
|
Common patterns:
|
|
22
|
-
#{program_name} --id
|
|
23
|
-
#{program_name} --id
|
|
24
|
-
#{program_name} --id
|
|
22
|
+
#{program_name} --id pi-i-42 --lines 80
|
|
23
|
+
#{program_name} --id pi-i-42 --follow
|
|
24
|
+
#{program_name} --id pi-i-42 --repo /path/to/repo --lines 200
|
|
25
25
|
|
|
26
26
|
Gotchas:
|
|
27
27
|
logs reads the persisted transcript, not the live tmux screen.
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
# `harnex mcp` (#87): serve the lane verbs over MCP stdio, or register that
|
|
3
|
+
# server with Claude Code (user scope) and Pi (~/.pi/agent/mcp.json).
|
|
4
|
+
class MCPCommand
|
|
5
|
+
SERVER_NAME = "harnex".freeze
|
|
6
|
+
PI_TIMEOUT_SECONDS = 7200
|
|
7
|
+
AGENT_SOURCE = File.expand_path("../../../agents/gpt.md", __dir__).freeze
|
|
8
|
+
|
|
9
|
+
def self.agent_path
|
|
10
|
+
File.expand_path("~/.claude/agents/gpt.md")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.usage
|
|
14
|
+
<<~TEXT
|
|
15
|
+
Usage: harnex mcp Serve MCP over stdio (what MCP clients run)
|
|
16
|
+
harnex mcp install [--claude] [--pi] [--no-agent] [--dry-run]
|
|
17
|
+
harnex mcp uninstall [--claude] [--pi]
|
|
18
|
+
harnex mcp status
|
|
19
|
+
|
|
20
|
+
Exposes ask, delegate, result, reply, land, discard and lanes as native tools.
|
|
21
|
+
install registers the server for Claude Code (`claude mcp add --scope user`)
|
|
22
|
+
and Pi (#{pi_config_path}, exposure direct). With neither flag it does both
|
|
23
|
+
(each only when that CLI is installed). For Claude Code it also installs the
|
|
24
|
+
`gpt` subagent at ~/.claude/agents/gpt.md unless one exists (--no-agent skips it).
|
|
25
|
+
Restart running sessions afterwards.
|
|
26
|
+
TEXT
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Where the agent-facing integrations stand (doctor reports this; it
|
|
30
|
+
# reads config files only, so it never starts a client).
|
|
31
|
+
def self.integrations
|
|
32
|
+
claude = begin
|
|
33
|
+
JSON.parse(File.read(File.expand_path("~/.claude.json"))).dig("mcpServers", SERVER_NAME)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
pi = begin
|
|
38
|
+
JSON.parse(File.read(pi_config_path)).dig("mcpServers", SERVER_NAME)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
result = { claude_mcp: !claude.nil?, pi_mcp: !pi.nil?,
|
|
43
|
+
gpt_agent: File.exist?(agent_path) }
|
|
44
|
+
result[:hint] = "run `harnex mcp install` for native tools in Claude Code and Pi" unless claude && pi
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.pi_config_path
|
|
49
|
+
dir = ENV["PI_AGENT_DIR"] || ENV["PI_CODING_AGENT_DIR"] || File.expand_path("~/.pi/agent")
|
|
50
|
+
File.join(dir, "mcp.json")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(argv, out: $stdout, err: $stderr)
|
|
54
|
+
@argv = argv.dup
|
|
55
|
+
@out = out
|
|
56
|
+
@err = err
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run
|
|
60
|
+
action = @argv.first.to_s.start_with?("-") || @argv.empty? ? "serve" : @argv.shift
|
|
61
|
+
if @argv.include?("-h") || @argv.include?("--help") || action == "help"
|
|
62
|
+
@out.puts self.class.usage
|
|
63
|
+
return 0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
case action
|
|
67
|
+
when "serve" then MCP::Server.new.run
|
|
68
|
+
when "install" then install
|
|
69
|
+
when "uninstall" then uninstall
|
|
70
|
+
when "status" then status
|
|
71
|
+
else
|
|
72
|
+
raise OptionParser::InvalidArgument, "harnex mcp: unknown action #{action.inspect}; see harnex mcp --help"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def targets
|
|
79
|
+
chosen = %w[claude pi].select { |name| @argv.include?("--#{name}") }
|
|
80
|
+
chosen.empty? ? %w[claude pi] : chosen
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def dry_run?
|
|
84
|
+
@argv.include?("--dry-run")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The RubyGems wrapper next to this Ruby runs the newest installed harnex
|
|
88
|
+
# without depending on a version-manager shim or the client's cwd.
|
|
89
|
+
def server_command
|
|
90
|
+
wrapper = File.join(Gem.bindir, "harnex")
|
|
91
|
+
File.executable?(wrapper) ? wrapper : "harnex"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def install
|
|
95
|
+
targets.map { |target| send("install_#{target}") }.max
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def uninstall
|
|
99
|
+
targets.map { |target| send("uninstall_#{target}") }.max
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def install_claude
|
|
103
|
+
return skip("claude", "the claude CLI is not on PATH") unless which("claude")
|
|
104
|
+
|
|
105
|
+
command = ["claude", "mcp", "add", "--transport", "stdio", "--scope", "user", SERVER_NAME, "--", server_command, "mcp"]
|
|
106
|
+
if dry_run?
|
|
107
|
+
say("claude", "would run: #{command.join(' ')}")
|
|
108
|
+
return install_agent
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Open3.capture3("claude", "mcp", "remove", SERVER_NAME, "--scope", "user")
|
|
112
|
+
out, status = Open3.capture2e(*command)
|
|
113
|
+
unless status.success?
|
|
114
|
+
@err.puts("harnex mcp: claude mcp add failed: #{out.strip}")
|
|
115
|
+
return 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
say("claude", "registered (user scope): #{server_command} mcp")
|
|
119
|
+
install_agent
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The `gpt` subagent relays a brief to a worker through these tools. A
|
|
123
|
+
# user's own agent of that name is never replaced.
|
|
124
|
+
def install_agent
|
|
125
|
+
return 0 if @argv.include?("--no-agent")
|
|
126
|
+
|
|
127
|
+
path = self.class.agent_path
|
|
128
|
+
return say("claude", "kept the existing gpt agent at #{path}") if File.exist?(path) || File.symlink?(path)
|
|
129
|
+
return say("claude", "would install the gpt agent at #{path}") if dry_run?
|
|
130
|
+
|
|
131
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
132
|
+
FileUtils.cp(AGENT_SOURCE, path)
|
|
133
|
+
say("claude", "installed the gpt agent at #{path} (Agent subagent_type \"gpt\")")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def uninstall_claude
|
|
137
|
+
return skip("claude", "the claude CLI is not on PATH") unless which("claude")
|
|
138
|
+
|
|
139
|
+
out, status = Open3.capture2e("claude", "mcp", "remove", SERVER_NAME, "--scope", "user")
|
|
140
|
+
say("claude", status.success? ? "removed" : "not registered (#{out.strip.lines.last.to_s.strip})")
|
|
141
|
+
path = self.class.agent_path
|
|
142
|
+
if File.file?(path) && !File.symlink?(path) && File.read(path) == File.read(AGENT_SOURCE)
|
|
143
|
+
FileUtils.rm_f(path)
|
|
144
|
+
say("claude", "removed the gpt agent harnex installed")
|
|
145
|
+
end
|
|
146
|
+
0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def install_pi
|
|
150
|
+
path = self.class.pi_config_path
|
|
151
|
+
return skip("pi", "#{File.dirname(path)} does not exist") unless File.directory?(File.dirname(path))
|
|
152
|
+
|
|
153
|
+
config = read_json(path)
|
|
154
|
+
config["mcpServers"] ||= {}
|
|
155
|
+
config["mcpServers"][SERVER_NAME] = {
|
|
156
|
+
"command" => server_command, "args" => ["mcp"], "exposure" => "direct", "timeout" => PI_TIMEOUT_SECONDS,
|
|
157
|
+
"description" => "Hand work to Pi workers (GPT models): ask, delegate, result, reply, land, discard, lanes"
|
|
158
|
+
}
|
|
159
|
+
return say("pi", "would write #{path}: #{JSON.generate(config['mcpServers'][SERVER_NAME])}") if dry_run?
|
|
160
|
+
|
|
161
|
+
Harnex.atomic_write_json(path, config)
|
|
162
|
+
say("pi", "registered in #{path} (exposure direct, timeout #{PI_TIMEOUT_SECONDS}s)")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def uninstall_pi
|
|
166
|
+
path = self.class.pi_config_path
|
|
167
|
+
config = read_json(path)
|
|
168
|
+
return say("pi", "not registered") unless config.dig("mcpServers", SERVER_NAME)
|
|
169
|
+
|
|
170
|
+
config["mcpServers"].delete(SERVER_NAME)
|
|
171
|
+
Harnex.atomic_write_json(path, config)
|
|
172
|
+
say("pi", "removed from #{path}")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def status
|
|
176
|
+
claude = if which("claude")
|
|
177
|
+
out, ok = Open3.capture2e("claude", "mcp", "get", SERVER_NAME)
|
|
178
|
+
ok.success? ? "registered: #{out.lines.grep(/Command|Args|Scope/i).map(&:strip).join('; ')}" : "not registered"
|
|
179
|
+
else
|
|
180
|
+
"claude CLI not found"
|
|
181
|
+
end
|
|
182
|
+
pi = read_json(self.class.pi_config_path).dig("mcpServers", SERVER_NAME)
|
|
183
|
+
@out.puts "claude: #{claude}"
|
|
184
|
+
@out.puts "pi: #{pi ? "registered: #{pi['command']} #{Array(pi['args']).join(' ')} (exposure #{pi['exposure'] || 'codemode'})" : 'not registered'}"
|
|
185
|
+
0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def read_json(path)
|
|
189
|
+
JSON.parse(File.read(path))
|
|
190
|
+
rescue Errno::ENOENT
|
|
191
|
+
{}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def which(name)
|
|
195
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def say(target, text)
|
|
199
|
+
@out.puts "#{target}: #{text}"
|
|
200
|
+
0
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def skip(target, reason)
|
|
204
|
+
@out.puts "#{target}: skipped (#{reason})"
|
|
205
|
+
0
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|