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,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