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,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Diffbroker
|
|
8
|
+
# Opens a visible terminal running the task's run script (spec §4.2 step 6). The user sees and drives the agent.
|
|
9
|
+
class Launcher
|
|
10
|
+
def initialize(name:, runner: Runner.new, platform: Diffbroker.platform,
|
|
11
|
+
warp_dir: File.join(Dir.home, ".warp", "launch_configurations"))
|
|
12
|
+
raise Error, "Unknown terminal #{name.inspect}. Use one of: #{Config::TERMINALS.join(", ")}" unless Config::TERMINALS.include?(name)
|
|
13
|
+
|
|
14
|
+
@name = name
|
|
15
|
+
@runner = runner
|
|
16
|
+
@platform = platform
|
|
17
|
+
@warp_dir = warp_dir
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def launch!(script:, cwd:, title:)
|
|
21
|
+
commands(script:, cwd:, title:).each { @runner.run!(*_1) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def commands(script:, cwd:, title:)
|
|
25
|
+
title = title.to_s.gsub(/[^\w.#-]/, "-")
|
|
26
|
+
line = "/bin/bash #{Shellwords.escape(script)}"
|
|
27
|
+
|
|
28
|
+
case @name
|
|
29
|
+
when "terminal"
|
|
30
|
+
[ [ "osascript", "-e", %(tell application "Terminal" to do script #{applescript(line)}),
|
|
31
|
+
"-e", %(tell application "Terminal" to activate) ] ]
|
|
32
|
+
when "iterm2"
|
|
33
|
+
[ [ "osascript", "-e", %(tell application "iTerm" to create window with default profile command #{applescript(line)}),
|
|
34
|
+
"-e", %(tell application "iTerm" to activate) ] ]
|
|
35
|
+
when "ghostty"
|
|
36
|
+
@platform == "macos" ? [ [ "open", "-na", "Ghostty", "--args", "-e", "/bin/bash", script ] ] : [ [ "ghostty", "-e", "/bin/bash", script ] ]
|
|
37
|
+
when "warp"
|
|
38
|
+
[ [ @platform == "macos" ? "open" : "xdg-open", "warp://launch/#{warp_config(line:, cwd:, title:)}" ] ]
|
|
39
|
+
when "tmux"
|
|
40
|
+
if @runner.success?("tmux", "has-session")
|
|
41
|
+
[ [ "tmux", "new-window", "-n", title, "-c", cwd, line ] ]
|
|
42
|
+
else
|
|
43
|
+
[ [ "tmux", "new-session", "-d", "-s", "diffbroker", "-n", title, "-c", cwd, line ] ]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# An AppleScript string literal: backslashes and double quotes escaped.
|
|
51
|
+
def applescript(text) = %("#{text.gsub("\\") { "\\\\" }.gsub('"') { '\\"' }}")
|
|
52
|
+
|
|
53
|
+
def warp_config(line:, cwd:, title:)
|
|
54
|
+
FileUtils.mkdir_p(@warp_dir)
|
|
55
|
+
file = "diffbroker-#{title}.yaml"
|
|
56
|
+
config = { "name" => "Diff Broker #{title}",
|
|
57
|
+
"windows" => [ { "tabs" => [ { "title" => title, "layout" => { "cwd" => cwd, "commands" => [ { "exec" => line } ] } } ] } ] }
|
|
58
|
+
File.write(File.join(@warp_dir, file), config.to_yaml)
|
|
59
|
+
file
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Diffbroker
|
|
7
|
+
# Stopping, restarting and spawning the companion on this machine: used by `diffbroker stop` / `restart` / `launch` and
|
|
8
|
+
# by the daemon when the website sends a "control" message. A background service (launchd / systemd) is stopped and
|
|
9
|
+
# restarted through its manager, otherwise it would resurrect a stopped daemon (KeepAlive / Restart=always).
|
|
10
|
+
class Lifecycle
|
|
11
|
+
PID_FILE = "daemon.pid"
|
|
12
|
+
WAIT = 10
|
|
13
|
+
|
|
14
|
+
def initialize(dir:, home: Dir.home, service: ServiceInstaller.new(home:), runner: Runner.new, exe: ServiceInstaller.launcher,
|
|
15
|
+
ruby: RbConfig.ruby, spawner: nil, sleeper: ->(seconds) { sleep(seconds) }, pid: Process.pid, env: ENV, server: nil)
|
|
16
|
+
@dir = dir
|
|
17
|
+
@home = home
|
|
18
|
+
@service = service
|
|
19
|
+
@runner = runner
|
|
20
|
+
@exe = exe
|
|
21
|
+
@ruby = ruby
|
|
22
|
+
@spawner = spawner || method(:spawn_process)
|
|
23
|
+
@sleeper = sleeper
|
|
24
|
+
@pid = pid
|
|
25
|
+
@env = env
|
|
26
|
+
@server = server
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :service
|
|
30
|
+
|
|
31
|
+
def pid_path = File.join(@dir, PID_FILE)
|
|
32
|
+
def service_installed? = @service.installed?
|
|
33
|
+
def log_path = File.join(@home, ".config", "diffbroker", "companion.log")
|
|
34
|
+
|
|
35
|
+
def write_pid
|
|
36
|
+
FileUtils.mkdir_p(@dir)
|
|
37
|
+
File.write(pid_path, @pid.to_s)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def clear_pid
|
|
41
|
+
File.delete(pid_path) if File.exist?(pid_path) && File.read(pid_path).to_i == @pid
|
|
42
|
+
rescue SystemCallError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True only for the daemon the service manager started (launchd sets XPC_SERVICE_NAME to the job's label, systemd sets
|
|
47
|
+
# INVOCATION_ID). A daemon started by hand (`diffbroker start --server http://localhost:3000`) is not the service's:
|
|
48
|
+
# asking the manager to restart or stop "the service" would act on a different process (the one pointed at the
|
|
49
|
+
# default server) and leave this one running its old code for ever.
|
|
50
|
+
def service_process? = @env["XPC_SERVICE_NAME"] == ServiceInstaller::LABEL || @env.key?("INVOCATION_ID")
|
|
51
|
+
|
|
52
|
+
# Used by the daemon itself: false when this is not the service's daemon, no service is installed or the manager refused.
|
|
53
|
+
def stop_service = service_process? && @service.installed? && @service.stop!
|
|
54
|
+
def restart_service = service_process? && @service.installed? && @service.restart!
|
|
55
|
+
|
|
56
|
+
# Starts the background service, or the daemon itself when none is installed. False when a service was refused.
|
|
57
|
+
def launch
|
|
58
|
+
return true if @service.start!
|
|
59
|
+
|
|
60
|
+
spawn_daemon
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def spawn_daemon
|
|
64
|
+
FileUtils.mkdir_p(File.dirname(log_path))
|
|
65
|
+
@spawner.call([ @ruby, @exe, "start" ], log_path)
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The service (if any) is stopped through its manager; a daemon started by hand is signalled by its pid file.
|
|
70
|
+
def stop
|
|
71
|
+
stopped = @service.installed? && @service.stop!
|
|
72
|
+
terminated = terminate_running
|
|
73
|
+
stopped || terminated
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Through the manager when a service is installed (it loads the freshly installed gem), otherwise stop and start again.
|
|
77
|
+
def restart
|
|
78
|
+
return true if @service.installed? && @service.restart!
|
|
79
|
+
|
|
80
|
+
terminate_running
|
|
81
|
+
spawn_daemon
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The running daemon's pid, only when it is alive and really a diffbroker process (a stale file may name a recycled pid).
|
|
85
|
+
def running_pid
|
|
86
|
+
pid = File.exist?(pid_path) ? File.read(pid_path).to_i : 0
|
|
87
|
+
return nil if pid <= 1 || pid == @pid
|
|
88
|
+
|
|
89
|
+
Process.kill(0, pid)
|
|
90
|
+
@runner.capture("ps", "-p", pid.to_s, "-o", "command=").include?("diffbroker") ? pid : nil
|
|
91
|
+
rescue SystemCallError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def terminate_running
|
|
98
|
+
pid = running_pid
|
|
99
|
+
return false unless pid
|
|
100
|
+
|
|
101
|
+
Process.kill("TERM", pid)
|
|
102
|
+
WAIT.times do
|
|
103
|
+
return true unless alive?(pid)
|
|
104
|
+
|
|
105
|
+
@sleeper.call(0.5)
|
|
106
|
+
end
|
|
107
|
+
Process.kill("KILL", pid) if alive?(pid)
|
|
108
|
+
true
|
|
109
|
+
rescue SystemCallError
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def alive?(pid)
|
|
114
|
+
Process.kill(0, pid)
|
|
115
|
+
true
|
|
116
|
+
rescue SystemCallError
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The respawned daemon talks to the server this one did (a daemon started by hand against a local server must not come
|
|
121
|
+
# back pointed at the default one).
|
|
122
|
+
def spawn_process(argv, log)
|
|
123
|
+
env = @server ? { "DIFFBROKER_SERVER" => @server } : {}
|
|
124
|
+
Process.detach(Process.spawn(env, *argv, in: File::NULL, out: [ log, "a" ], err: [ log, "a" ], pgroup: true))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "io/wait"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Diffbroker
|
|
9
|
+
# Unix socket between `diffbroker hook` (one JSON object per line) and the daemon. Owner-only (0600); the daemon
|
|
10
|
+
# forwards over its authenticated Action Cable connection.
|
|
11
|
+
class LocalRelay
|
|
12
|
+
MAX_LINE = 4_096
|
|
13
|
+
MAX_QUEUE = 1_000
|
|
14
|
+
|
|
15
|
+
attr_reader :path
|
|
16
|
+
|
|
17
|
+
def self.send_line(path, line, timeout: 0.2)
|
|
18
|
+
return false unless File.socket?(path)
|
|
19
|
+
|
|
20
|
+
socket = Socket.new(:UNIX, :STREAM)
|
|
21
|
+
begin
|
|
22
|
+
socket.connect_nonblock(Socket.sockaddr_un(path))
|
|
23
|
+
rescue IO::WaitWritable
|
|
24
|
+
return false unless socket.wait_writable(timeout)
|
|
25
|
+
end
|
|
26
|
+
written = socket.write_nonblock("#{line}\n", exception: false)
|
|
27
|
+
return false if written == :wait_writable && !socket.wait_writable(timeout)
|
|
28
|
+
|
|
29
|
+
true
|
|
30
|
+
rescue SystemCallError, IOError, ArgumentError
|
|
31
|
+
false
|
|
32
|
+
ensure
|
|
33
|
+
socket&.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(path:)
|
|
37
|
+
@path = path
|
|
38
|
+
@queue = Queue.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def start
|
|
42
|
+
if File.exist?(@path)
|
|
43
|
+
raise Error, "Another diffbroker daemon is already running (#{@path})" if self.class.send_line(@path, "{}", timeout: 0.1)
|
|
44
|
+
|
|
45
|
+
File.delete(@path)
|
|
46
|
+
end
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
|
|
48
|
+
previous = File.umask(0o177)
|
|
49
|
+
begin
|
|
50
|
+
@server = UNIXServer.new(@path)
|
|
51
|
+
ensure
|
|
52
|
+
File.umask(previous)
|
|
53
|
+
end
|
|
54
|
+
File.chmod(0o600, @path)
|
|
55
|
+
@thread = Thread.new { accept_loop }
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def drain
|
|
60
|
+
items = []
|
|
61
|
+
items << @queue.pop(true) while items.size < MAX_QUEUE && !@queue.empty?
|
|
62
|
+
items
|
|
63
|
+
rescue ThreadError
|
|
64
|
+
items
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only a relay that started removes the socket file, so a failed start never unlinks a live daemon's socket.
|
|
68
|
+
def stop
|
|
69
|
+
return unless @server
|
|
70
|
+
|
|
71
|
+
@server.close
|
|
72
|
+
@thread&.kill
|
|
73
|
+
File.delete(@path) if File.socket?(@path)
|
|
74
|
+
@server = nil
|
|
75
|
+
rescue IOError, SystemCallError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def accept_loop
|
|
82
|
+
loop { read_client(@server.accept) }
|
|
83
|
+
rescue IOError, SystemCallError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def read_client(client)
|
|
88
|
+
return unless client.wait_readable(1)
|
|
89
|
+
|
|
90
|
+
line = client.gets(MAX_LINE)
|
|
91
|
+
data = line && JSON.parse(line)
|
|
92
|
+
# `{}` is the liveness probe from #start; empty objects are dropped.
|
|
93
|
+
@queue << data if data.is_a?(Hash) && !data.empty? && @queue.size < MAX_QUEUE
|
|
94
|
+
rescue JSON::ParserError, IOError, SystemCallError
|
|
95
|
+
nil
|
|
96
|
+
ensure
|
|
97
|
+
client.close
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Diffbroker
|
|
6
|
+
# RFC 8628-style device-code pairing against POST /device_authorizations (spec §4.1).
|
|
7
|
+
class Login
|
|
8
|
+
def self.open_browser(url)
|
|
9
|
+
system(Diffbroker.platform == "macos" ? "open" : "xdg-open", url, out: File::NULL, err: File::NULL)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(config:, api: Api.new(server: config.server), out: $stdout, name: Socket.gethostname,
|
|
13
|
+
sleeper: ->(seconds) { sleep(seconds) }, opener: ->(url) { Login.open_browser(url) }, clock: -> { Time.now })
|
|
14
|
+
@config = config
|
|
15
|
+
@api = api
|
|
16
|
+
@out = out
|
|
17
|
+
@name = name
|
|
18
|
+
@sleeper = sleeper
|
|
19
|
+
@opener = opener
|
|
20
|
+
@clock = clock
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
auth = @api.post("/device_authorizations", { client_name: @name, platform: Diffbroker.platform })
|
|
25
|
+
code = auth.fetch("user_code")
|
|
26
|
+
uri = auth.fetch("verification_uri")
|
|
27
|
+
# No pre-filled link: nothing in a URL may select a code, the user types it on the plain page. The companion never
|
|
28
|
+
# touches the clipboard.
|
|
29
|
+
@out.puts "Pairing #{@name} with Diff Broker."
|
|
30
|
+
@out.puts
|
|
31
|
+
@out.puts " Your code: #{code}"
|
|
32
|
+
@out.puts
|
|
33
|
+
@out.puts "Enter it at #{uri}, press Pair, then check the device details and press Approve."
|
|
34
|
+
@out.puts "Only enter a code you see here, in your own terminal."
|
|
35
|
+
@opener.call(uri)
|
|
36
|
+
poll(auth)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def poll(auth)
|
|
42
|
+
interval = auth.fetch("interval", 5).to_i
|
|
43
|
+
deadline = @clock.call + auth.fetch("expires_in", 600).to_i
|
|
44
|
+
|
|
45
|
+
loop do
|
|
46
|
+
raise Error, "The pairing code expired. Run `diffbroker start` again." if @clock.call > deadline
|
|
47
|
+
|
|
48
|
+
@sleeper.call(interval)
|
|
49
|
+
begin
|
|
50
|
+
# RFC 8628: the device code goes in the body, never the URL (request paths end up in logs).
|
|
51
|
+
token = @api.post("/device_authorizations/token", { device_code: auth.fetch("device_code") })
|
|
52
|
+
rescue Api::HttpError => e
|
|
53
|
+
case e.body["error"]
|
|
54
|
+
when "authorization_pending" then next
|
|
55
|
+
when "slow_down" then interval += 5
|
|
56
|
+
when "expired_token" then raise Error, "The pairing code expired. Run `diffbroker start` again."
|
|
57
|
+
else raise Error, "Pairing failed: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@config.save_credentials!(device_token: token.fetch("access_token"), device_id: token.fetch("device_id"))
|
|
63
|
+
@out.puts "Paired."
|
|
64
|
+
return token["device_id"]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Diffbroker
|
|
6
|
+
# Upgrading and uninstalling the companion itself: fixed command sequences, nothing taken from the website's message.
|
|
7
|
+
class Maintenance
|
|
8
|
+
GEM_NAME = "diffbroker"
|
|
9
|
+
|
|
10
|
+
# `hooks`: the user's Claude Code hooks (HooksInstaller). `launcher`: what the service and the hooks run.
|
|
11
|
+
def initialize(runner: Runner.new, bindir: RbConfig::CONFIG["bindir"], hooks: HooksInstaller.default,
|
|
12
|
+
launcher: ServiceInstaller.launcher)
|
|
13
|
+
@runner = runner
|
|
14
|
+
@gem = File.join(bindir, "gem")
|
|
15
|
+
@hooks = hooks
|
|
16
|
+
@launcher = launcher
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Installs the newest release, then removes the old versions. The running process loads all its code first: cleanup
|
|
20
|
+
# deletes the files of the version it runs. Hooks written by an older version name its versioned exe, so they are
|
|
21
|
+
# rewritten to the launcher first. With no RubyGems wrapper the service runs this version's own exe, so nothing is
|
|
22
|
+
# cleaned up. Raises Error (with gem's output) when the install fails.
|
|
23
|
+
def upgrade!
|
|
24
|
+
@runner.run!(@gem, "install", GEM_NAME, "--no-document")
|
|
25
|
+
Diffbroker.eager_load!
|
|
26
|
+
refresh_hooks
|
|
27
|
+
@runner.success?(@gem, "cleanup", GEM_NAME) unless ServiceInstaller.pinned?(@launcher) # a failure is harmless
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# True when the gem was uninstalled. The service goes last: stopping it ends the daemon when the daemon is the one
|
|
32
|
+
# uninstalling, so the gem's failure text (nil on success) is yielded before that step, for a report that gets out.
|
|
33
|
+
def uninstall!(service:, url_handler:)
|
|
34
|
+
Diffbroker.eager_load!
|
|
35
|
+
remove_hooks # they would run a missing file on every Claude Code event
|
|
36
|
+
error = uninstall_gem
|
|
37
|
+
url_handler.uninstall!
|
|
38
|
+
yield error if block_given?
|
|
39
|
+
service.uninstall!
|
|
40
|
+
error.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# A broken settings file never blocks an upgrade or an uninstall.
|
|
46
|
+
def refresh_hooks
|
|
47
|
+
@hooks.install! if @hooks.installed?
|
|
48
|
+
rescue Error
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def remove_hooks
|
|
53
|
+
@hooks.uninstall!
|
|
54
|
+
rescue Error
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def uninstall_gem
|
|
59
|
+
@runner.run!(@gem, "uninstall", GEM_NAME, "--all", "--executables", "--ignore-dependencies")
|
|
60
|
+
nil
|
|
61
|
+
rescue Error => e
|
|
62
|
+
e.message
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Diffbroker
|
|
6
|
+
# Untrusted heads only (spec §4.2 step 3, §5.1; understanding spec §5.3): the PR's own agent configuration never
|
|
7
|
+
# becomes active. Repo-controlled config is removed (restoring it from base is not enough: base hooks would still
|
|
8
|
+
# run); CLAUDE.md / AGENTS.md come back from the base branch. Every touched tracked path is marked --skip-worktree,
|
|
9
|
+
# so `git add -A` can never commit the neutralization.
|
|
10
|
+
class Neutralizer
|
|
11
|
+
CONFIG_DIRS = %w[.claude .codex].freeze
|
|
12
|
+
REMOVE = [ *CONFIG_DIRS.flat_map { [ ":(glob)**/#{_1}", ":(glob)**/#{_1}/**" ] }, ":(glob)**/.mcp.json",
|
|
13
|
+
":(glob)**/AGENTS.override.md", ":(glob)**/CLAUDE.local.md", ":(glob)**/.gitattributes" ].freeze
|
|
14
|
+
RESTORE = [ ":(glob)**/CLAUDE.md", ":(glob)**/AGENTS.md" ].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(runner: Runner.new)
|
|
17
|
+
@runner = runner
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def neutralize!(dir, repo_path:, number:, base_ref:, remote: "origin")
|
|
21
|
+
raise Error, "Invalid base branch #{base_ref.inspect}" unless Worktree.valid_ref?(base_ref)
|
|
22
|
+
raise Error, "Invalid PR number #{number.inspect}" unless number.to_s.match?(/\A[1-9]\d{0,9}\z/)
|
|
23
|
+
|
|
24
|
+
@dir = File.realpath(dir)
|
|
25
|
+
base = "refs/diffbroker/base-#{number}"
|
|
26
|
+
@runner.git(repo_path, "fetch", "--quiet", remote, "+refs/heads/#{base_ref}:#{base}")
|
|
27
|
+
|
|
28
|
+
removed = paths(@runner.git(@dir, "ls-files", "-z", "--", *REMOVE))
|
|
29
|
+
removed.each { delete(_1) }
|
|
30
|
+
removed += untracked_config
|
|
31
|
+
(CONFIG_DIRS + [ ".mcp.json" ]).each { delete(_1, recursive: true) }
|
|
32
|
+
|
|
33
|
+
restored = restore_from(base)
|
|
34
|
+
touched = (removed + restored).uniq
|
|
35
|
+
tracked = touched.empty? ? [] : paths(@runner.git(@dir, "ls-files", "-z", "--", *touched.map { ":(literal)#{_1}" }))
|
|
36
|
+
@runner.git(@dir, "update-index", "--skip-worktree", "--", *tracked) if tracked.any?
|
|
37
|
+
touched.sort
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# CLAUDE.md / AGENTS.md the PR modified come back from base; ones it added are deleted. Ones it deleted stay
|
|
43
|
+
# deleted: restored, they would be untracked files that `git add -A` could commit.
|
|
44
|
+
def restore_from(base)
|
|
45
|
+
fields = paths(@runner.git(@dir, "diff", "-z", "--name-status", "--no-renames", "#{base}...HEAD", "--", *RESTORE))
|
|
46
|
+
fields.each_slice(2).filter_map do |status, path|
|
|
47
|
+
case status
|
|
48
|
+
when "A" then delete(path)
|
|
49
|
+
when "D" then next
|
|
50
|
+
else @runner.git(@dir, "restore", "--source=#{base}", "--worktree", "--", ":(literal)#{path}")
|
|
51
|
+
end
|
|
52
|
+
path
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Untracked agent config at the top of the worktree (never from the PR, but never trusted either).
|
|
57
|
+
def untracked_config
|
|
58
|
+
(CONFIG_DIRS + [ ".mcp.json" ]).flat_map do |name|
|
|
59
|
+
path = File.join(@dir, name)
|
|
60
|
+
next [] unless File.exist?(path) && !File.symlink?(path)
|
|
61
|
+
next [ name ] unless File.directory?(path)
|
|
62
|
+
|
|
63
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: path).reject { File.directory?(File.join(path, _1)) }.map { "#{name}/#{_1}" }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only ever deletes inside the worktree: a path whose parent resolves elsewhere (a symlinked directory) is skipped.
|
|
68
|
+
def delete(relative, recursive: false)
|
|
69
|
+
full = File.join(@dir, relative)
|
|
70
|
+
parent = File.realpath(File.dirname(full))
|
|
71
|
+
return unless parent == @dir || parent.start_with?("#{@dir}/")
|
|
72
|
+
|
|
73
|
+
recursive ? FileUtils.rm_rf(full) : FileUtils.rm_f(full)
|
|
74
|
+
rescue SystemCallError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def paths(output) = output.split("\0").reject(&:empty?)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Diffbroker
|
|
6
|
+
# Every tick (30 s): which known coding agents run, and in which GitHub checkout. A process says nothing about
|
|
7
|
+
# working or waiting, so this reports only "running" and "exit" (main spec §18.4). A process's cwd is untrusted:
|
|
8
|
+
# it is resolved from .git files only, never by running git there.
|
|
9
|
+
class ProcessScanner
|
|
10
|
+
AGENTS = { "claude" => "claude_code", "codex" => "codex", "aider" => "aider", "cursor-agent" => "cursor" }.freeze
|
|
11
|
+
LAUNCHERS = /\A(?:node|python[\d.]*|bun|deno)\z/
|
|
12
|
+
|
|
13
|
+
def self.agent_for(args)
|
|
14
|
+
tokens = args.to_s.split
|
|
15
|
+
name = File.basename(tokens[0].to_s)
|
|
16
|
+
name = File.basename(tokens[1].to_s) if name.match?(LAUNCHERS)
|
|
17
|
+
AGENTS[name.sub(/\.(?:js|mjs|cjs|py)\z/, "")]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# skip_claude: true/false, or a callable checked on every scan (Claude sessions with hooks installed report
|
|
21
|
+
# themselves, with real working/waiting status).
|
|
22
|
+
def initialize(runner: Runner.new, platform: Diffbroker.platform, skip_claude: false, exclude: [], proc_root: "/proc",
|
|
23
|
+
resolver: ->(cwd) { GitContext.resolve(cwd) }, clock: -> { Time.now })
|
|
24
|
+
@runner = runner
|
|
25
|
+
@platform = platform
|
|
26
|
+
@skip_claude = skip_claude
|
|
27
|
+
@exclude = exclude.map { File.expand_path(_1) }
|
|
28
|
+
@proc_root = proc_root
|
|
29
|
+
@resolver = resolver
|
|
30
|
+
@clock = clock
|
|
31
|
+
@known = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def scan
|
|
35
|
+
now = @clock.call.utc.iso8601
|
|
36
|
+
current = live_agents
|
|
37
|
+
running = current.map do |id, info|
|
|
38
|
+
{ "event" => "running", "agent" => info[:agent], "external_session_id" => id, "occurred_at" => now }.merge(info[:context])
|
|
39
|
+
end
|
|
40
|
+
exited = (@known.keys - current.keys).map do |id|
|
|
41
|
+
{ "event" => "exit", "agent" => @known[id], "external_session_id" => id, "occurred_at" => now }
|
|
42
|
+
end
|
|
43
|
+
@known = current.transform_values { _1[:agent] }
|
|
44
|
+
running + exited
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# pid => cwd for the given pids (lsof on macOS, /proc on Linux); a pid that vanished is left out.
|
|
48
|
+
def working_dirs(pids)
|
|
49
|
+
return {} if pids.empty?
|
|
50
|
+
return pids.to_h { [ _1, proc_cwd(_1) ] }.compact if @platform == "linux"
|
|
51
|
+
|
|
52
|
+
cwds = {}
|
|
53
|
+
pid = nil
|
|
54
|
+
@runner.capture("lsof", "-a", "-d", "cwd", "-Fn", "-p", pids.sort.join(",")).each_line do |line|
|
|
55
|
+
case line[0]
|
|
56
|
+
when "p" then pid = line[1..].to_i
|
|
57
|
+
when "n" then cwds[pid] = line[1..].chomp if pid
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
cwds
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# { "proc-<pid>" => { agent:, context: } }, one per (agent, cwd), only in GitHub checkouts outside `exclude`.
|
|
66
|
+
def live_agents
|
|
67
|
+
agents = agent_processes
|
|
68
|
+
cwds = working_dirs(agents.keys)
|
|
69
|
+
seen = {}
|
|
70
|
+
agents.sort.each_with_object({}) do |(pid, agent), current|
|
|
71
|
+
cwd = cwds[pid]
|
|
72
|
+
next if cwd.nil? || seen[[ agent, cwd ]] || excluded?(cwd)
|
|
73
|
+
|
|
74
|
+
context = @resolver.call(cwd) or next
|
|
75
|
+
seen[[ agent, cwd ]] = true
|
|
76
|
+
current["proc-#{pid}"] = { agent:, context: }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def skip_claude? = @skip_claude.respond_to?(:call) ? @skip_claude.call : @skip_claude
|
|
81
|
+
def excluded?(cwd) = @exclude.any? { cwd == _1 || cwd.start_with?("#{_1}/") }
|
|
82
|
+
|
|
83
|
+
def agent_processes
|
|
84
|
+
skip_claude = skip_claude?
|
|
85
|
+
@runner.run!("ps", "-Ao", "pid=,args=").lines.each_with_object({}) do |line, found|
|
|
86
|
+
pid, args = line.strip.split(" ", 2)
|
|
87
|
+
next unless pid.to_s.match?(/\A\d+\z/) && pid.to_i != Process.pid
|
|
88
|
+
|
|
89
|
+
agent = self.class.agent_for(args)
|
|
90
|
+
next if agent.nil? || (skip_claude && agent == "claude_code")
|
|
91
|
+
|
|
92
|
+
found[pid.to_i] = agent
|
|
93
|
+
end
|
|
94
|
+
rescue Error
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def proc_cwd(pid)
|
|
99
|
+
File.readlink(File.join(@proc_root, pid.to_s, "cwd"))
|
|
100
|
+
rescue SystemCallError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|