diffbroker 0.9.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/diffbroker +10 -0
  4. data/lib/diffbroker/agent_projects.rb +35 -0
  5. data/lib/diffbroker/agents/base.rb +48 -0
  6. data/lib/diffbroker/agents/claude.rb +100 -0
  7. data/lib/diffbroker/agents/codex.rb +72 -0
  8. data/lib/diffbroker/agents.rb +18 -0
  9. data/lib/diffbroker/api.rb +59 -0
  10. data/lib/diffbroker/branch_publisher.rb +66 -0
  11. data/lib/diffbroker/cable_client.rb +213 -0
  12. data/lib/diffbroker/cli.rb +347 -0
  13. data/lib/diffbroker/config.rb +126 -0
  14. data/lib/diffbroker/daemon.rb +277 -0
  15. data/lib/diffbroker/explain_verifier.rb +55 -0
  16. data/lib/diffbroker/gh_check.rb +22 -0
  17. data/lib/diffbroker/git_context.rb +115 -0
  18. data/lib/diffbroker/hook.rb +50 -0
  19. data/lib/diffbroker/hooks_installer.rb +115 -0
  20. data/lib/diffbroker/launcher.rb +62 -0
  21. data/lib/diffbroker/lifecycle.rb +127 -0
  22. data/lib/diffbroker/local_relay.rb +100 -0
  23. data/lib/diffbroker/login.rb +68 -0
  24. data/lib/diffbroker/maintenance.rb +65 -0
  25. data/lib/diffbroker/neutralizer.rb +80 -0
  26. data/lib/diffbroker/process_scanner.rb +104 -0
  27. data/lib/diffbroker/reaper.rb +111 -0
  28. data/lib/diffbroker/repo_scanner.rb +88 -0
  29. data/lib/diffbroker/run_script.rb +62 -0
  30. data/lib/diffbroker/runner.rb +41 -0
  31. data/lib/diffbroker/service_installer.rb +179 -0
  32. data/lib/diffbroker/skill_installer.rb +30 -0
  33. data/lib/diffbroker/task_files.rb +90 -0
  34. data/lib/diffbroker/task_runner.rb +286 -0
  35. data/lib/diffbroker/url_handler_installer.rb +111 -0
  36. data/lib/diffbroker/version.rb +5 -0
  37. data/lib/diffbroker/worktree.rb +115 -0
  38. data/lib/diffbroker.rb +46 -0
  39. data/plugin/.claude-plugin/plugin.json +5 -0
  40. data/plugin/skills/diffbroker/SKILL.md +36 -0
  41. data/plugin/skills/diffbroker-explain/SKILL.md +141 -0
  42. metadata +99 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Diffbroker
4
+ # Stops the processes an agent leaves behind in a task worktree. Tools such as `rubocop --server`, spring or a language
5
+ # server detach from the agent (their parent becomes launchd/init), so they outlive it and its process group, keep the
6
+ # worktree as their working directory and pile up for days. Only processes whose working directory is inside the
7
+ # worktree root are ever touched, and only by pid: a process is never found by name or killed by group.
8
+ class Reaper
9
+ GRACE = 3
10
+ POLL = 0.1
11
+ INIT = /\A(?:\S*\/)?(?:systemd|launchd)\b/
12
+
13
+ def initialize(root:, runner: Runner.new, platform: Diffbroker.platform, proc_root: "/proc", grace: GRACE,
14
+ signal: Process.method(:kill), sleeper: ->(seconds) { sleep(seconds) })
15
+ @root = real(root)
16
+ @runner = runner
17
+ @scanner = ProcessScanner.new(runner:, platform:, proc_root:)
18
+ @grace = grace
19
+ @signal = signal
20
+ @sleeper = sleeper
21
+ end
22
+
23
+ # A task ended in `dir`: whatever still runs there is a stray. Returns how many processes were stopped.
24
+ def reap!(dir)
25
+ dir = real(dir)
26
+ return 0 unless inside?(dir, @root)
27
+
28
+ stop(processes.select { inside?(_1[:cwd], dir) }.map { _1[:pid] })
29
+ rescue Error, SystemCallError
30
+ 0
31
+ end
32
+
33
+ # Worktrees nobody works in (no agent process inside): their orphaned helpers are strays. A worktree with a live
34
+ # agent (a terminal run the user is still in) is left alone. Call it only when no task is running here.
35
+ def sweep!
36
+ pids = processes.group_by { worktree_of(_1[:cwd]) }.flat_map do |dir, inside|
37
+ next [] if dir.nil? || inside.any? { ProcessScanner.agent_for(_1[:args]) }
38
+
39
+ inside.select { _1[:orphan] }.map { _1[:pid] }
40
+ end
41
+ stop(pids)
42
+ rescue Error, SystemCallError
43
+ 0
44
+ end
45
+
46
+ private
47
+
48
+ # [{ pid:, args:, cwd:, orphan: }] for every process with a working directory under the worktree root.
49
+ def processes
50
+ table = @runner.run!("ps", "-Ao", "pid=,ppid=,args=").lines.filter_map do |line|
51
+ pid, ppid, args = line.strip.split(" ", 3)
52
+ next unless pid.to_s.match?(/\A\d+\z/) && ppid.to_s.match?(/\A\d+\z/) && pid.to_i > 1 && pid.to_i != Process.pid
53
+
54
+ { pid: pid.to_i, ppid: ppid.to_i, args: args.to_s }
55
+ end
56
+ args_of = table.to_h { [ _1[:pid], _1[:args] ] }
57
+ cwds = @scanner.working_dirs(table.map { _1[:pid] })
58
+ table.filter_map do |process|
59
+ cwd = cwds[process[:pid]]
60
+ next unless cwd && inside?(cwd, @root)
61
+
62
+ process.merge(cwd:, orphan: process[:ppid] == 1 || args_of[process[:ppid]].to_s.match?(INIT))
63
+ end
64
+ end
65
+
66
+ # <root>/<owner>-<repo>/pr-<n>-<kind>, whatever depth the process is working at.
67
+ def worktree_of(cwd)
68
+ return unless cwd.start_with?("#{@root}/")
69
+
70
+ owner_repo, pr = cwd.delete_prefix("#{@root}/").split("/", 3).first(2)
71
+ File.join(@root, owner_repo, pr) if pr
72
+ end
73
+
74
+ def stop(pids)
75
+ pids = pids.uniq.select { deliver("TERM", _1) }
76
+ return 0 if pids.empty?
77
+
78
+ (@grace / POLL).ceil.times do
79
+ break if pids.none? { alive?(_1) }
80
+
81
+ @sleeper.call(POLL)
82
+ end
83
+ pids.each { deliver("KILL", _1) if alive?(_1) }
84
+ pids.size
85
+ end
86
+
87
+ def deliver(name, pid)
88
+ @signal.call(name, pid)
89
+ true
90
+ rescue SystemCallError
91
+ false
92
+ end
93
+
94
+ def alive?(pid)
95
+ @signal.call(0, pid)
96
+ true
97
+ rescue Errno::EPERM
98
+ true
99
+ rescue SystemCallError
100
+ false
101
+ end
102
+
103
+ def inside?(path, dir) = path == dir || path.start_with?("#{dir}/")
104
+
105
+ def real(path)
106
+ File.realpath(path)
107
+ rescue SystemCallError
108
+ File.expand_path(path)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Diffbroker
4
+ # Finds GitHub checkouts under the configured roots and reports them (DeviceRepository), so a card only offers
5
+ # agents where a checkout exists. Reads .git/config as a file; never runs git in scanned directories.
6
+ class RepoScanner
7
+ GITHUB_URL = %r{\A(?:https://(?:[^@/\s]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/)([\w.-]+)/([\w.-]+?)(?:\.git)?/?\z}
8
+ SKIP = %w[node_modules vendor tmp log].freeze
9
+
10
+ def self.full_name_from(url)
11
+ match = GITHUB_URL.match(url.to_s.strip)
12
+ match && "#{match[1]}/#{match[2]}".downcase
13
+ end
14
+
15
+ # `hints` are directories known to hold work (the agents' project lists): each counts as a checkout when it, or a
16
+ # parent up to HINT_LEVELS above it, is one, however deep it is. Its parent folder is scanned too (HINT_PARENT_DEPTH
17
+ # levels), because the user keeps the other repositories next to it, where no agent has run. A parent that is the home
18
+ # folder (or above it) is never scanned: that is the whole account, not a place for code.
19
+ HINT_LEVELS = 4
20
+ HINT_PARENT_DEPTH = 2
21
+
22
+ # `exclude` (the task worktrees) is skipped everywhere. `ignored` (folders the user removed) is skipped by everything
23
+ # found by itself; `explicit_roots` (folders the user added) are scanned even inside an ignored folder.
24
+ def initialize(roots:, depth: 3, exclude: [], hints: [], home: Dir.home, explicit_roots: [], ignored: [])
25
+ @roots = roots
26
+ @explicit_roots = explicit_roots
27
+ @ignored = ignored.map { File.expand_path(_1) }
28
+ @hints = hints
29
+ @home = File.expand_path(home)
30
+ @depth = depth
31
+ @exclude = exclude.map { File.expand_path(_1) }
32
+ @skip = @exclude + @ignored
33
+ end
34
+
35
+ def scan
36
+ found = {}
37
+ @roots.each { |root| walk(File.expand_path(root), 0, found, @depth, @skip) if File.directory?(root) }
38
+ @explicit_roots.each { |root| walk(File.expand_path(root), 0, found, @depth, @exclude) if File.directory?(root) }
39
+ parents = @hints.filter_map { |hint| add_hint(File.expand_path(hint), found) }.map { File.dirname(_1) }.uniq
40
+ parents.each { |parent| walk(parent, 0, found, HINT_PARENT_DEPTH, @skip) if scannable_parent?(parent) }
41
+ found.sort.map { |full_name, path| { "full_name" => full_name, "path" => path } }
42
+ end
43
+
44
+ private
45
+
46
+ # Records the checkout a hint is in and returns its directory (nil when it is not in one).
47
+ def add_hint(dir, found)
48
+ HINT_LEVELS.times do
49
+ return nil if @skip.any? { |skipped| dir == skipped || dir.start_with?("#{skipped}/") }
50
+ return nil unless File.directory?(dir)
51
+
52
+ if File.exist?(File.join(dir, ".git"))
53
+ remote_names(dir).each { |name| found[name] = dir if found[name].nil? || dir.length < found[name].length }
54
+ return dir
55
+ end
56
+ dir = File.dirname(dir)
57
+ end
58
+ nil
59
+ end
60
+
61
+ def scannable_parent?(dir)
62
+ return true if dir.start_with?("#{@home}/")
63
+
64
+ !(dir == @home || dir == "/" || @home.start_with?("#{dir}/"))
65
+ end
66
+
67
+ def walk(dir, level, found, limit, skip)
68
+ return if skip.any? { |skipped| dir == skipped || dir.start_with?("#{skipped}/") }
69
+
70
+ if File.exist?(File.join(dir, ".git"))
71
+ remote_names(dir).each { |name| found[name] = dir if found[name].nil? || dir.length < found[name].length }
72
+ return
73
+ end
74
+ return if level >= limit
75
+
76
+ Dir.children(dir).sort.each do |child|
77
+ next if child.start_with?(".") || SKIP.include?(child)
78
+
79
+ path = File.join(dir, child)
80
+ walk(path, level + 1, found, limit, skip) if File.directory?(path) && !File.symlink?(path)
81
+ end
82
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
83
+ nil
84
+ end
85
+
86
+ def remote_names(dir) = GitContext.remote_urls(dir).filter_map { |_name, url| self.class.full_name_from(url) }.uniq
87
+ end
88
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require "rbconfig"
6
+
7
+ module Diffbroker
8
+ # Terminal mode (spec §4.2 step 6): the script the terminal runs. The task token lives only here (0700, private
9
+ # state dir, deleted on start) and in the agent's environment. The prompt travels in an environment variable, so
10
+ # quoting never matters. When the agent exits, `diffbroker task-ended` tells Diff Broker.
11
+ class RunScript
12
+ TASK_ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
13
+
14
+ # RubyGems' stable wrapper (ServiceInstaller.launcher): a terminal task may outlive an upgrade's `gem cleanup`.
15
+ def initialize(state_dir:, ruby: RbConfig.ruby, exe: ServiceInstaller.launcher)
16
+ @state_dir = state_dir
17
+ @ruby = ruby
18
+ @exe = exe
19
+ end
20
+
21
+ def task_dir(task_id)
22
+ raise Error, "Invalid task id #{task_id.inspect}" unless task_id.to_s.match?(TASK_ID)
23
+
24
+ File.join(@state_dir, "tasks", task_id)
25
+ end
26
+
27
+ # publish: a command (argv) to run in this script, outside the agent, once the agent exited successfully: the push of
28
+ # an update_branch rebase (BranchPublisher), which the agent's own hooks would refuse.
29
+ def write!(task_id:, worktree:, token:, server:, argv:, prompt:, notice: nil, publish: nil)
30
+ dir = task_dir(task_id)
31
+ FileUtils.mkdir_p(dir, mode: 0o700)
32
+ File.chmod(0o700, dir)
33
+ path = File.join(dir, "run.sh")
34
+ FileUtils.rm_f(path)
35
+ File.write(path, render(task_id:, worktree:, token:, server:, argv:, prompt:, notice:, publish:), perm: 0o700)
36
+ File.chmod(0o700, path)
37
+ path
38
+ end
39
+
40
+ private
41
+
42
+ def render(task_id:, worktree:, token:, server:, argv:, prompt:, notice:, publish:)
43
+ inner = %(#{Shellwords.join(argv)} "$DIFFBROKER_PROMPT")
44
+ publish_step = publish ? "if [ \"$status\" = \"0\" ]; then #{Shellwords.join(publish)}; fi" : nil
45
+ <<~SH
46
+ #!/bin/bash
47
+ # Written by the Diff Broker companion for agent task #{task_id}. Deletes itself on start.
48
+ rm -f -- "$0"
49
+ #{"printf '%s\\n' #{Shellwords.escape(notice)}" if notice}
50
+ export DIFFBROKER_TASK_TOKEN=#{Shellwords.escape(token)}
51
+ export DIFFBROKER_TASK_ID=#{Shellwords.escape(task_id)}
52
+ export DIFFBROKER_SERVER=#{Shellwords.escape(server)}
53
+ export DIFFBROKER_PROMPT=#{Shellwords.escape(prompt)}
54
+ cd #{Shellwords.escape(worktree)} || exit 1
55
+ "${SHELL:-/bin/bash}" -lic #{Shellwords.escape(inner)}
56
+ status=$?
57
+ #{publish_step}
58
+ #{Shellwords.escape(@ruby)} #{Shellwords.escape(@exe)} task-ended "$status"
59
+ SH
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Diffbroker
6
+ class Runner
7
+ # Spec §4.1: never let a repository's own config run code through git.
8
+ SAFE_GIT = [ "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null" ].freeze
9
+ # Git exports these to hooks (and `git rebase` etc.); inherited, they would point every git command at another
10
+ # repository, so each subprocess starts without them.
11
+ GIT_ENV = %w[GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
12
+ GIT_NAMESPACE GIT_PREFIX GIT_QUARANTINE_PATH].to_h { [ _1, nil ] }.freeze
13
+
14
+ def run!(*argv, chdir: nil)
15
+ options = chdir ? { chdir: } : {}
16
+ out, status = Open3.capture2e(GIT_ENV, *argv, **options)
17
+ raise Error, "`#{argv.join(" ")}` failed: #{out.strip}" unless status.success?
18
+
19
+ out
20
+ rescue Errno::ENOENT
21
+ raise Error, "#{argv.first} not found on PATH"
22
+ end
23
+
24
+ def success?(*argv)
25
+ _out, status = Open3.capture2e(GIT_ENV, *argv)
26
+ status.success?
27
+ rescue Errno::ENOENT
28
+ false
29
+ end
30
+
31
+ def capture(*argv)
32
+ out, _status = Open3.capture2e(GIT_ENV, *argv)
33
+ out
34
+ rescue Errno::ENOENT
35
+ ""
36
+ end
37
+
38
+ def git(dir, *args) = run!("git", *SAFE_GIT, "-C", dir, *args)
39
+ def git_success?(dir, *args) = success?("git", *SAFE_GIT, "-C", dir, *args)
40
+ end
41
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+
6
+ module Diffbroker
7
+ # Runs `diffbroker start` in the background as the logged-in user (launchd on macOS, systemd --user on Linux).
8
+ class ServiceInstaller
9
+ LABEL = "broker.diff.companion"
10
+ EXE = File.join(Diffbroker::ROOT, "exe", "diffbroker")
11
+
12
+ # What the service and the "Start companion" app run. RubyGems' own wrapper activates the newest installed diffbroker
13
+ # on every start, so a restart loads an upgraded gem and old versions can be removed. The gem's own exe is the
14
+ # fallback (and what a test passes), pinned to the version that ran `install-service`.
15
+ def self.launcher(bindir: launcher_bindirs)
16
+ Array(bindir).map { File.join(_1, "diffbroker") }.find { File.executable?(_1) } || EXE
17
+ end
18
+
19
+ # Where RubyGems puts the wrapper: the default bindir, the bindir of the install location this gem came from, and
20
+ # the per-user one (`gem install --user-install`).
21
+ def self.launcher_bindirs
22
+ spec = Gem.loaded_specs["diffbroker"]
23
+ [ Gem.bindir, (Gem.bindir(spec.base_dir) if spec), File.join(Gem.user_dir, "bin") ].compact.uniq
24
+ end
25
+
26
+ # True when the launcher is this version's own exe, which `gem cleanup` deletes once a newer version is installed.
27
+ def self.pinned?(launcher = self.launcher) = launcher == EXE
28
+
29
+ def initialize(platform: Diffbroker.platform, home: Dir.home, ruby: RbConfig.ruby, exe: ServiceInstaller.launcher, env: ENV, runner: Runner.new,
30
+ uid: Process.uid)
31
+ @platform = platform
32
+ @home = home
33
+ @ruby = ruby
34
+ @exe = exe
35
+ @env = env
36
+ @runner = runner
37
+ @uid = uid
38
+ end
39
+
40
+ def install! = @platform == "macos" ? install_launchd! : install_systemd!
41
+
42
+ def installed? = File.exist?(@platform == "macos" ? launchd_path : systemd_path)
43
+
44
+ # Starts the installed service if it is not running (a running one is left alone). False when there is no
45
+ # service or the manager refused, so the caller can start the daemon itself.
46
+ def start!
47
+ return false unless installed?
48
+
49
+ if @platform == "macos"
50
+ @runner.success?("launchctl", "kickstart", target) || bootstrap_launchd
51
+ else
52
+ @runner.success?("systemctl", "--user", "start", "diffbroker.service")
53
+ end
54
+ end
55
+
56
+ # Stops the service through its manager so KeepAlive / Restart=always does not bring it back; `start!` undoes it
57
+ # (a booted-out launchd job is bootstrapped again).
58
+ def stop!
59
+ return false unless installed?
60
+
61
+ if @platform == "macos"
62
+ @runner.success?("launchctl", "bootout", target)
63
+ else
64
+ @runner.success?("systemctl", "--user", "stop", "diffbroker.service")
65
+ end
66
+ end
67
+
68
+ # Restarts the service so it runs the currently installed gem code. The manager does the restart: the daemon asking
69
+ # for it may be killed mid-call and the new process still starts.
70
+ def restart!
71
+ return false unless installed?
72
+
73
+ if @platform == "macos"
74
+ @runner.success?("launchctl", "kickstart", "-k", target) || bootstrap_launchd
75
+ else
76
+ @runner.success?("systemctl", "--user", "restart", "diffbroker.service")
77
+ end
78
+ end
79
+
80
+ # Stops and removes the background service. False when none was installed. The file goes first on macOS: booting
81
+ # out the job ends the daemon when it is the one uninstalling. systemd needs the unit file to disable it, so a
82
+ # daemon uninstalling itself may leave a disabled, stopped unit behind.
83
+ def uninstall!
84
+ return false unless installed?
85
+
86
+ if @platform == "macos"
87
+ File.delete(launchd_path)
88
+ @runner.success?("launchctl", "bootout", target)
89
+ else
90
+ @runner.success?("systemctl", "--user", "disable", "--now", "diffbroker.service")
91
+ File.delete(systemd_path)
92
+ @runner.success?("systemctl", "--user", "daemon-reload")
93
+ end
94
+ true
95
+ end
96
+
97
+ def plist
98
+ arguments = [ @ruby, @exe, "start" ].map { " <string>#{xml(_1)}</string>" }.join("\n")
99
+ environment = { "PATH" => @env["PATH"].to_s, "SHELL" => @env["SHELL"] || "/bin/zsh", "LANG" => @env["LANG"] || "en_US.UTF-8" }
100
+ .map { |key, value| " <key>#{key}</key>\n <string>#{xml(value)}</string>" }.join("\n")
101
+ log = xml(File.join(@home, "Library", "Logs", "diffbroker.log"))
102
+ <<~XML
103
+ <?xml version="1.0" encoding="UTF-8"?>
104
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
105
+ <plist version="1.0">
106
+ <dict>
107
+ <key>Label</key>
108
+ <string>#{LABEL}</string>
109
+ <key>ProgramArguments</key>
110
+ <array>
111
+ #{arguments}
112
+ </array>
113
+ <key>EnvironmentVariables</key>
114
+ <dict>
115
+ #{environment}
116
+ </dict>
117
+ <key>RunAtLoad</key>
118
+ <true/>
119
+ <key>KeepAlive</key>
120
+ <true/>
121
+ <key>StandardOutPath</key>
122
+ <string>#{log}</string>
123
+ <key>StandardErrorPath</key>
124
+ <string>#{log}</string>
125
+ </dict>
126
+ </plist>
127
+ XML
128
+ end
129
+
130
+ def unit
131
+ <<~UNIT
132
+ [Unit]
133
+ Description=Diff Broker companion (runs your own agents on your pull requests)
134
+ After=network-online.target
135
+
136
+ [Service]
137
+ ExecStart="#{@ruby}" "#{@exe}" start
138
+ Restart=always
139
+ RestartSec=5
140
+ Environment="PATH=#{@env["PATH"]}"
141
+ Environment="SHELL=#{@env["SHELL"] || "/bin/bash"}"
142
+
143
+ [Install]
144
+ WantedBy=default.target
145
+ UNIT
146
+ end
147
+
148
+ private
149
+
150
+ def target = "gui/#{@uid}/#{LABEL}"
151
+
152
+ # kickstart fails once the job was booted out (`stop!`): load it again, RunAtLoad starts it.
153
+ def bootstrap_launchd = @runner.success?("launchctl", "bootstrap", "gui/#{@uid}", launchd_path)
154
+
155
+ def xml(value) = value.to_s.encode(xml: :text)
156
+
157
+ def launchd_path = File.join(@home, "Library", "LaunchAgents", "#{LABEL}.plist")
158
+ def systemd_path = File.join(@home, ".config", "systemd", "user", "diffbroker.service")
159
+
160
+ def install_launchd!
161
+ path = launchd_path
162
+ FileUtils.mkdir_p(File.dirname(path))
163
+ FileUtils.mkdir_p(File.join(@home, "Library", "Logs"))
164
+ File.write(path, plist)
165
+ @runner.success?("launchctl", "bootout", "gui/#{@uid}/#{LABEL}") # not loaded yet is fine
166
+ @runner.run!("launchctl", "bootstrap", "gui/#{@uid}", path)
167
+ path
168
+ end
169
+
170
+ def install_systemd!
171
+ path = systemd_path
172
+ FileUtils.mkdir_p(File.dirname(path))
173
+ File.write(path, unit)
174
+ @runner.run!("systemctl", "--user", "daemon-reload")
175
+ @runner.run!("systemctl", "--user", "enable", "--now", "diffbroker.service")
176
+ path
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Diffbroker
6
+ # Copies the plugin's skills to ${CLAUDE_CONFIG_DIR:-~/.claude}/skills/<name>, for running your own agent without
7
+ # the companion (the copy command). Companion runs load them with --plugin-dir and need no install.
8
+ class SkillInstaller
9
+ SOURCE = File.join(Diffbroker::ROOT, "plugin", "skills")
10
+
11
+ def initialize(home: Dir.home, env: ENV, source: SOURCE)
12
+ @home = home
13
+ @env = env
14
+ @source = source
15
+ end
16
+
17
+ def install!
18
+ skills = Dir[File.join(@source, "*", "SKILL.md")].map { File.dirname(_1) }.sort
19
+ raise Error, "No skills found at #{@source}" if skills.empty?
20
+
21
+ skills.map do |skill|
22
+ target = File.join(@env["CLAUDE_CONFIG_DIR"] || File.join(@home, ".claude"), "skills", File.basename(skill))
23
+ FileUtils.rm_rf(target)
24
+ FileUtils.mkdir_p(File.dirname(target))
25
+ FileUtils.cp_r(skill, target)
26
+ target
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Diffbroker
7
+ # Per-task files. They reference the task token as ${DIFFBROKER_TASK_TOKEN}; the value only exists in the agent
8
+ # process environment. Spec §4.2 step 4: the Diff Broker MCP config lives outside the repository.
9
+ class TaskFiles
10
+ TOKEN_ENV = "DIFFBROKER_TASK_TOKEN"
11
+ HOOK_EVENTS = %w[SessionStart Stop SessionEnd].freeze
12
+ SETTINGS_PATH = File.join(".claude", "settings.local.json")
13
+
14
+ def initialize(task_dir:, worktree:, server:, task_id:, runner: Runner.new)
15
+ @task_dir = task_dir
16
+ @worktree = worktree
17
+ @server = server.to_s.chomp("/")
18
+ raise Error, "Invalid task id #{task_id.inspect}" unless task_id.to_s.match?(RunScript::TASK_ID)
19
+
20
+ @task_id = task_id
21
+ @runner = runner
22
+ end
23
+
24
+ def mcp_url = "#{@server}/mcp/messages"
25
+ def events_url = "#{@server}/agent_tasks/#{@task_id}/events"
26
+
27
+ def mcp_config!
28
+ FileUtils.mkdir_p(@task_dir, mode: 0o700)
29
+ File.chmod(0o700, @task_dir)
30
+ path = File.join(@task_dir, "mcp.json")
31
+ FileUtils.rm_f(path)
32
+ server = { "type" => "http", "url" => mcp_url, "headers" => { "x-task-token" => "${#{TOKEN_ENV}}" } }
33
+ File.write(path, "#{JSON.pretty_generate({ "mcpServers" => { "diffbroker" => server } })}\n", perm: 0o600)
34
+ File.chmod(0o600, path)
35
+ path
36
+ end
37
+
38
+ # Terminal mode with Claude Code (spec §4.2 step 6): HTTP hooks report start, stop and end.
39
+ def claude_hooks!
40
+ if @runner.git_success?(@worktree, "ls-files", "--error-unmatch", SETTINGS_PATH)
41
+ raise Error, "The repository tracks #{SETTINGS_PATH}; Diff Broker will not edit tracked files."
42
+ end
43
+
44
+ path = File.join(@worktree, SETTINGS_PATH)
45
+ if [ File.dirname(path), path ].any? { File.symlink?(_1) }
46
+ raise Error, "#{SETTINGS_PATH} in #{@worktree} is a symlink; Diff Broker will not write through it."
47
+ end
48
+
49
+ FileUtils.mkdir_p(File.dirname(path))
50
+ data = read_json(path)
51
+ existing = data["hooks"] || {}
52
+ raise Error, "#{path} has a \"hooks\" value that is not an object; fix it, then start the task again" unless existing.is_a?(Hash)
53
+
54
+ data["hooks"] = existing.merge(hooks) { |_event, theirs, ours| Array(theirs).reject { ours?(_1) } + ours }
55
+ File.write(path, "#{JSON.pretty_generate(data)}\n")
56
+ exclude_settings
57
+ end
58
+
59
+ def cleanup! = FileUtils.rm_rf(@task_dir)
60
+
61
+ private
62
+
63
+ def hooks
64
+ hook = { "type" => "http", "url" => events_url, "headers" => { "Authorization" => "Bearer ${#{TOKEN_ENV}}" },
65
+ "allowedEnvVars" => [ TOKEN_ENV ], "timeout" => 10 }
66
+ HOOK_EVENTS.to_h { [ _1, [ { "hooks" => [ hook ] } ] ] }
67
+ end
68
+
69
+ def ours?(group)
70
+ group.is_a?(Hash) && Array(group["hooks"]).any? { _1.is_a?(Hash) && _1["url"].to_s.start_with?("#{@server}/agent_tasks/") }
71
+ end
72
+
73
+ def exclude_settings
74
+ exclude = File.expand_path(@runner.git(@worktree, "rev-parse", "--git-path", "info/exclude").strip, @worktree)
75
+ FileUtils.mkdir_p(File.dirname(exclude))
76
+ content = File.exist?(exclude) ? File.read(exclude) : ""
77
+ entry = "/#{SETTINGS_PATH}"
78
+ return if content.lines(chomp: true).include?(entry)
79
+
80
+ File.write(exclude, "#{content}#{"\n" unless content.empty? || content.end_with?("\n")}#{entry}\n")
81
+ end
82
+
83
+ def read_json(path)
84
+ data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
85
+ data.is_a?(Hash) ? data : raise(JSON::ParserError)
86
+ rescue JSON::ParserError
87
+ raise Error, "#{path} is not valid JSON; fix or remove it, then start the task again"
88
+ end
89
+ end
90
+ end