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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/exe/diffbroker +10 -0
- data/lib/diffbroker/agent_projects.rb +35 -0
- data/lib/diffbroker/agents/base.rb +48 -0
- data/lib/diffbroker/agents/claude.rb +100 -0
- data/lib/diffbroker/agents/codex.rb +72 -0
- data/lib/diffbroker/agents.rb +18 -0
- data/lib/diffbroker/api.rb +59 -0
- data/lib/diffbroker/branch_publisher.rb +66 -0
- data/lib/diffbroker/cable_client.rb +213 -0
- data/lib/diffbroker/cli.rb +347 -0
- data/lib/diffbroker/config.rb +126 -0
- data/lib/diffbroker/daemon.rb +277 -0
- data/lib/diffbroker/explain_verifier.rb +55 -0
- data/lib/diffbroker/gh_check.rb +22 -0
- data/lib/diffbroker/git_context.rb +115 -0
- data/lib/diffbroker/hook.rb +50 -0
- data/lib/diffbroker/hooks_installer.rb +115 -0
- data/lib/diffbroker/launcher.rb +62 -0
- data/lib/diffbroker/lifecycle.rb +127 -0
- data/lib/diffbroker/local_relay.rb +100 -0
- data/lib/diffbroker/login.rb +68 -0
- data/lib/diffbroker/maintenance.rb +65 -0
- data/lib/diffbroker/neutralizer.rb +80 -0
- data/lib/diffbroker/process_scanner.rb +104 -0
- data/lib/diffbroker/reaper.rb +111 -0
- data/lib/diffbroker/repo_scanner.rb +88 -0
- data/lib/diffbroker/run_script.rb +62 -0
- data/lib/diffbroker/runner.rb +41 -0
- data/lib/diffbroker/service_installer.rb +179 -0
- data/lib/diffbroker/skill_installer.rb +30 -0
- data/lib/diffbroker/task_files.rb +90 -0
- data/lib/diffbroker/task_runner.rb +286 -0
- data/lib/diffbroker/url_handler_installer.rb +111 -0
- data/lib/diffbroker/version.rb +5 -0
- data/lib/diffbroker/worktree.rb +115 -0
- data/lib/diffbroker.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +5 -0
- data/plugin/skills/diffbroker/SKILL.md +36 -0
- data/plugin/skills/diffbroker-explain/SKILL.md +141 -0
- 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
|