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
@@ -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[codex pi].freeze
7
+ SUPPORTED_ADAPTERS = %w[pi].freeze
9
8
 
10
9
  def self.usage
11
10
  <<~TEXT
12
- Usage: harnex doctor [--adapter codex|pi|all] [--sweep] [--prune [--dry-run]]
11
+ Usage: harnex doctor [--adapter pi|all] [--sweep] [--prune [--dry-run]]
13
12
 
14
- Runs static preflight checks for harnex adapter dependencies. Codex is
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 codex, pi, or all (repeatable; default: codex)
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 { |name| name == "pi" ? check_pi : check_codex }
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 codex|pi|all] [--sweep] [--prune [--dry-run]]"
79
- opts.on("--adapter NAME", "Check codex, pi, or all (repeatable)") { |value| @options[:adapters] << value.to_s.downcase }
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 ["codex"] if requested.empty?
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 cx-i-42 --snapshot
24
- #{program_name} --id cx-i-42
25
- #{program_name} --id cx-i-42 --from 2026-05-06T10:00:00Z --snapshot
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
- File.readlines(path, chomp: true).filter_map do |line|
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
@@ -19,9 +19,9 @@ module Harnex
19
19
  -h, --help Show this help
20
20
 
21
21
  Common patterns:
22
- #{program_name} --id cx-i-42 --lines 80
23
- #{program_name} --id cx-i-42 --follow
24
- #{program_name} --id cx-i-42 --repo /path/to/repo --lines 200
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