gitbroker 0.3.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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/gitbroker +10 -0
  4. data/lib/gitbroker/agents/base.rb +48 -0
  5. data/lib/gitbroker/agents/claude.rb +92 -0
  6. data/lib/gitbroker/agents/codex.rb +72 -0
  7. data/lib/gitbroker/agents.rb +18 -0
  8. data/lib/gitbroker/api.rb +53 -0
  9. data/lib/gitbroker/cable_client.rb +213 -0
  10. data/lib/gitbroker/cli.rb +199 -0
  11. data/lib/gitbroker/config.rb +88 -0
  12. data/lib/gitbroker/daemon.rb +166 -0
  13. data/lib/gitbroker/explain_verifier.rb +31 -0
  14. data/lib/gitbroker/gh_check.rb +22 -0
  15. data/lib/gitbroker/git_context.rb +105 -0
  16. data/lib/gitbroker/hook.rb +50 -0
  17. data/lib/gitbroker/hooks_installer.rb +114 -0
  18. data/lib/gitbroker/launcher.rb +62 -0
  19. data/lib/gitbroker/lifecycle.rb +114 -0
  20. data/lib/gitbroker/local_relay.rb +100 -0
  21. data/lib/gitbroker/login.rb +61 -0
  22. data/lib/gitbroker/neutralizer.rb +80 -0
  23. data/lib/gitbroker/process_scanner.rb +103 -0
  24. data/lib/gitbroker/repo_scanner.rb +50 -0
  25. data/lib/gitbroker/run_script.rb +58 -0
  26. data/lib/gitbroker/runner.rb +41 -0
  27. data/lib/gitbroker/service_installer.rb +145 -0
  28. data/lib/gitbroker/skill_installer.rb +30 -0
  29. data/lib/gitbroker/task_files.rb +90 -0
  30. data/lib/gitbroker/task_runner.rb +258 -0
  31. data/lib/gitbroker/url_handler_installer.rb +87 -0
  32. data/lib/gitbroker/version.rb +5 -0
  33. data/lib/gitbroker/worktree.rb +93 -0
  34. data/lib/gitbroker.rb +39 -0
  35. data/plugin/.claude-plugin/plugin.json +5 -0
  36. data/plugin/skills/gitbroker/SKILL.md +36 -0
  37. data/plugin/skills/gitbroker-explain/SKILL.md +141 -0
  38. metadata +95 -0
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "rbconfig"
6
+ require "shellwords"
7
+
8
+ module Gitbroker
9
+ # Adds `gitbroker hook <event>` command hooks to the user-level Claude Code settings (opt-in, main spec §18.4).
10
+ # Merges into the existing file: every other setting and the user's own hooks stay, our entries are replaced (never
11
+ # duplicated), an unchanged file is not rewritten, and a changed one is backed up first. No token is ever written.
12
+ class HooksInstaller
13
+ EVENTS = %w[SessionStart UserPromptSubmit Stop SessionEnd].freeze
14
+ OURS = /gitbroker hook (?:#{EVENTS.join("|")})\z/
15
+ TIMEOUT = 5
16
+
17
+ attr_reader :settings_path
18
+
19
+ def self.default(env: ENV, home: Dir.home)
20
+ new(settings_path: File.join(env["CLAUDE_CONFIG_DIR"] || File.join(home, ".claude"), "settings.json"),
21
+ command_prefix: "#{Shellwords.escape(RbConfig.ruby)} #{Shellwords.escape(ServiceInstaller::EXE)}")
22
+ end
23
+
24
+ def initialize(settings_path:, command_prefix:, clock: -> { Time.now })
25
+ @settings_path = settings_path
26
+ @command_prefix = command_prefix
27
+ @clock = clock
28
+ end
29
+
30
+ def installed?
31
+ hooks = read["hooks"] || {}
32
+ EVENTS.all? { |event| Array(hooks[event]).any? { ours_group?(_1) } }
33
+ end
34
+
35
+ # Returns the backup path, or nil when there was nothing to back up (new file) or nothing changed.
36
+ def install!
37
+ data = read
38
+ hooks = strip_ours(data["hooks"] || {})
39
+ EVENTS.each { |event| hooks[event] = Array(hooks[event]) + [ group_for(event) ] }
40
+ write(data, data.merge("hooks" => hooks))
41
+ end
42
+
43
+ def uninstall!
44
+ return nil unless File.exist?(settings_path)
45
+
46
+ data = read
47
+ return nil unless data.key?("hooks")
48
+
49
+ hooks = strip_ours(data["hooks"]).reject { |_event, groups| groups.empty? }
50
+ updated = data.dup
51
+ hooks.empty? ? updated.delete("hooks") : updated["hooks"] = hooks
52
+ write(data, updated)
53
+ end
54
+
55
+ private
56
+
57
+ def group_for(event) = { "hooks" => [ { "type" => "command", "command" => "#{@command_prefix} hook #{event}", "timeout" => TIMEOUT } ] }
58
+ def ours_hook?(hook) = hook.is_a?(Hash) && hook["command"].to_s.match?(OURS)
59
+ def ours_group?(group) = group.is_a?(Hash) && Array(group["hooks"]).any? { ours_hook?(_1) }
60
+
61
+ def strip_ours(hooks)
62
+ hooks.to_h do |event, groups|
63
+ kept = Array(groups).filter_map do |group|
64
+ next group unless group.is_a?(Hash) && group["hooks"].is_a?(Array)
65
+
66
+ remaining = group["hooks"].reject { ours_hook?(_1) }
67
+ next nil if remaining.empty? && group["hooks"].any?
68
+
69
+ group.merge("hooks" => remaining)
70
+ end
71
+ [ event, kept ]
72
+ end
73
+ end
74
+
75
+ def read
76
+ return {} unless File.exist?(settings_path)
77
+
78
+ data = JSON.parse(File.read(settings_path))
79
+ raise Error, "#{settings_path} is not a JSON object; fix it first (nothing was changed)" unless data.is_a?(Hash)
80
+ unless data["hooks"].nil? || data["hooks"].is_a?(Hash)
81
+ raise Error, "#{settings_path} has a \"hooks\" value that is not an object; fix it first (nothing was changed)"
82
+ end
83
+
84
+ data
85
+ rescue JSON::ParserError
86
+ raise Error, "#{settings_path} is not valid JSON; fix it first (nothing was changed)"
87
+ end
88
+
89
+ def write(before, after)
90
+ exists = File.exist?(settings_path)
91
+ return nil if exists && before == after
92
+
93
+ FileUtils.mkdir_p(File.dirname(settings_path))
94
+ backup = exists ? backup! : nil
95
+ temp = "#{settings_path}.gitbroker-#{Process.pid}.tmp"
96
+ File.write(temp, "#{JSON.pretty_generate(after)}\n")
97
+ File.chmod(File.stat(settings_path).mode & 0o777, temp) if exists
98
+ File.rename(temp, settings_path)
99
+ backup
100
+ ensure
101
+ FileUtils.rm_f(temp) if temp
102
+ end
103
+
104
+ # Never overwrites an earlier backup, even within the same second.
105
+ def backup!
106
+ base = "#{settings_path}.gitbroker-backup-#{@clock.call.utc.strftime("%Y%m%d%H%M%S")}"
107
+ path = base
108
+ counter = 0
109
+ path = "#{base}-#{counter += 1}" while File.exist?(path)
110
+ FileUtils.cp(settings_path, path, preserve: true)
111
+ path
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "yaml"
5
+ require "fileutils"
6
+
7
+ module Gitbroker
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: Gitbroker.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", "gitbroker", "-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 = "gitbroker-#{title}.yaml"
56
+ config = { "name" => "GitBroker #{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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rbconfig"
5
+
6
+ module Gitbroker
7
+ # Stopping, restarting and spawning the companion on this machine: used by `gitbroker 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::EXE,
15
+ ruby: RbConfig.ruby, spawner: nil, sleeper: ->(seconds) { sleep(seconds) }, pid: Process.pid)
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
+ end
26
+
27
+ def pid_path = File.join(@dir, PID_FILE)
28
+ def service_installed? = @service.installed?
29
+ def log_path = File.join(@home, ".config", "gitbroker", "companion.log")
30
+
31
+ def write_pid
32
+ FileUtils.mkdir_p(@dir)
33
+ File.write(pid_path, @pid.to_s)
34
+ end
35
+
36
+ def clear_pid
37
+ File.delete(pid_path) if File.exist?(pid_path) && File.read(pid_path).to_i == @pid
38
+ rescue SystemCallError
39
+ nil
40
+ end
41
+
42
+ # Used by the daemon itself: false when no service is installed or the manager refused.
43
+ def stop_service = @service.installed? && @service.stop!
44
+ def restart_service = @service.installed? && @service.restart!
45
+
46
+ # Starts the background service, or the daemon itself when none is installed. False when a service was refused.
47
+ def launch
48
+ return true if @service.start!
49
+
50
+ spawn_daemon
51
+ end
52
+
53
+ def spawn_daemon
54
+ FileUtils.mkdir_p(File.dirname(log_path))
55
+ @spawner.call([ @ruby, @exe, "start" ], log_path)
56
+ true
57
+ end
58
+
59
+ # The service (if any) is stopped through its manager; a daemon started by hand is signalled by its pid file.
60
+ def stop
61
+ stopped = @service.installed? && @service.stop!
62
+ terminated = terminate_running
63
+ stopped || terminated
64
+ end
65
+
66
+ # Through the manager when a service is installed (it loads the freshly installed gem), otherwise stop and start again.
67
+ def restart
68
+ return true if @service.installed? && @service.restart!
69
+
70
+ terminate_running
71
+ spawn_daemon
72
+ end
73
+
74
+ # The running daemon's pid, only when it is alive and really a gitbroker process (a stale file may name a recycled pid).
75
+ def running_pid
76
+ pid = File.exist?(pid_path) ? File.read(pid_path).to_i : 0
77
+ return nil if pid <= 1 || pid == @pid
78
+
79
+ Process.kill(0, pid)
80
+ @runner.capture("ps", "-p", pid.to_s, "-o", "command=").include?("gitbroker") ? pid : nil
81
+ rescue SystemCallError
82
+ nil
83
+ end
84
+
85
+ private
86
+
87
+ def terminate_running
88
+ pid = running_pid
89
+ return false unless pid
90
+
91
+ Process.kill("TERM", pid)
92
+ WAIT.times do
93
+ return true unless alive?(pid)
94
+
95
+ @sleeper.call(0.5)
96
+ end
97
+ Process.kill("KILL", pid) if alive?(pid)
98
+ true
99
+ rescue SystemCallError
100
+ false
101
+ end
102
+
103
+ def alive?(pid)
104
+ Process.kill(0, pid)
105
+ true
106
+ rescue SystemCallError
107
+ false
108
+ end
109
+
110
+ def spawn_process(argv, log)
111
+ Process.detach(Process.spawn(*argv, in: File::NULL, out: [ log, "a" ], err: [ log, "a" ], pgroup: true))
112
+ end
113
+ end
114
+ 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 Gitbroker
9
+ # Unix socket between `gitbroker 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 gitbroker 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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Gitbroker
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(Gitbroker.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: Gitbroker.platform })
25
+ # No pre-filled link: the user types the code on the plain page.
26
+ @out.puts "To pair this machine, open #{auth.fetch("verification_uri")} and type the code: #{auth.fetch("user_code")}"
27
+ @out.puts "Approve only if the page shows this machine's name (#{@name}) and the code above."
28
+ @opener.call(auth.fetch("verification_uri"))
29
+ poll(auth)
30
+ end
31
+
32
+ private
33
+
34
+ def poll(auth)
35
+ interval = auth.fetch("interval", 5).to_i
36
+ deadline = @clock.call + auth.fetch("expires_in", 600).to_i
37
+
38
+ loop do
39
+ raise Error, "The pairing code expired. Run `gitbroker login` again." if @clock.call > deadline
40
+
41
+ @sleeper.call(interval)
42
+ begin
43
+ # RFC 8628: the device code goes in the body, never the URL (request paths end up in logs).
44
+ token = @api.post("/device_authorizations/token", { device_code: auth.fetch("device_code") })
45
+ rescue Api::HttpError => e
46
+ case e.body["error"]
47
+ when "authorization_pending" then next
48
+ when "slow_down" then interval += 5
49
+ when "expired_token" then raise Error, "The pairing code expired. Run `gitbroker login` again."
50
+ else raise Error, "Pairing failed: #{e.message}"
51
+ end
52
+ next
53
+ end
54
+
55
+ @config.save_credentials!(device_token: token.fetch("access_token"), device_id: token.fetch("device_id"))
56
+ @out.puts "Paired. Next: `gitbroker install-service` (or `gitbroker start`)."
57
+ return token["device_id"]
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Gitbroker
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:)
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/gitbroker/base-#{number}"
26
+ @runner.git(repo_path, "fetch", "--quiet", "origin", "+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,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Gitbroker
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: Gitbroker.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
+ private
48
+
49
+ # { "proc-<pid>" => { agent:, context: } }, one per (agent, cwd), only in GitHub checkouts outside `exclude`.
50
+ def live_agents
51
+ agents = agent_processes
52
+ cwds = working_dirs(agents.keys)
53
+ seen = {}
54
+ agents.sort.each_with_object({}) do |(pid, agent), current|
55
+ cwd = cwds[pid]
56
+ next if cwd.nil? || seen[[ agent, cwd ]] || excluded?(cwd)
57
+
58
+ context = @resolver.call(cwd) or next
59
+ seen[[ agent, cwd ]] = true
60
+ current["proc-#{pid}"] = { agent:, context: }
61
+ end
62
+ end
63
+
64
+ def skip_claude? = @skip_claude.respond_to?(:call) ? @skip_claude.call : @skip_claude
65
+ def excluded?(cwd) = @exclude.any? { cwd == _1 || cwd.start_with?("#{_1}/") }
66
+
67
+ def agent_processes
68
+ skip_claude = skip_claude?
69
+ @runner.run!("ps", "-Ao", "pid=,args=").lines.each_with_object({}) do |line, found|
70
+ pid, args = line.strip.split(" ", 2)
71
+ next unless pid.to_s.match?(/\A\d+\z/) && pid.to_i != Process.pid
72
+
73
+ agent = self.class.agent_for(args)
74
+ next if agent.nil? || (skip_claude && agent == "claude_code")
75
+
76
+ found[pid.to_i] = agent
77
+ end
78
+ rescue Error
79
+ {}
80
+ end
81
+
82
+ def working_dirs(pids)
83
+ return {} if pids.empty?
84
+ return pids.to_h { [ _1, proc_cwd(_1) ] }.compact if @platform == "linux"
85
+
86
+ cwds = {}
87
+ pid = nil
88
+ @runner.capture("lsof", "-a", "-d", "cwd", "-Fn", "-p", pids.sort.join(",")).each_line do |line|
89
+ case line[0]
90
+ when "p" then pid = line[1..].to_i
91
+ when "n" then cwds[pid] = line[1..].chomp if pid
92
+ end
93
+ end
94
+ cwds
95
+ end
96
+
97
+ def proc_cwd(pid)
98
+ File.readlink(File.join(@proc_root, pid.to_s, "cwd"))
99
+ rescue SystemCallError
100
+ nil
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitbroker
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://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
+ def initialize(roots:, depth: 3, exclude: [])
16
+ @roots = roots
17
+ @depth = depth
18
+ @exclude = exclude.map { File.expand_path(_1) }
19
+ end
20
+
21
+ def scan
22
+ found = {}
23
+ @roots.each { |root| walk(File.expand_path(root), 0, found) if File.directory?(root) }
24
+ found.sort.map { |full_name, path| { "full_name" => full_name, "path" => path } }
25
+ end
26
+
27
+ private
28
+
29
+ def walk(dir, level, found)
30
+ return if @exclude.any? { |excluded| dir == excluded || dir.start_with?("#{excluded}/") }
31
+
32
+ if File.exist?(File.join(dir, ".git"))
33
+ remote_names(dir).each { |name| found[name] = dir if found[name].nil? || dir.length < found[name].length }
34
+ return
35
+ end
36
+ return if level >= @depth
37
+
38
+ Dir.children(dir).sort.each do |child|
39
+ next if child.start_with?(".") || SKIP.include?(child)
40
+
41
+ path = File.join(dir, child)
42
+ walk(path, level + 1, found) if File.directory?(path) && !File.symlink?(path)
43
+ end
44
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
45
+ nil
46
+ end
47
+
48
+ def remote_names(dir) = GitContext.remote_urls(dir).filter_map { |_name, url| self.class.full_name_from(url) }.uniq
49
+ end
50
+ end