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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/exe/gitbroker +10 -0
- data/lib/gitbroker/agents/base.rb +48 -0
- data/lib/gitbroker/agents/claude.rb +92 -0
- data/lib/gitbroker/agents/codex.rb +72 -0
- data/lib/gitbroker/agents.rb +18 -0
- data/lib/gitbroker/api.rb +53 -0
- data/lib/gitbroker/cable_client.rb +213 -0
- data/lib/gitbroker/cli.rb +199 -0
- data/lib/gitbroker/config.rb +88 -0
- data/lib/gitbroker/daemon.rb +166 -0
- data/lib/gitbroker/explain_verifier.rb +31 -0
- data/lib/gitbroker/gh_check.rb +22 -0
- data/lib/gitbroker/git_context.rb +105 -0
- data/lib/gitbroker/hook.rb +50 -0
- data/lib/gitbroker/hooks_installer.rb +114 -0
- data/lib/gitbroker/launcher.rb +62 -0
- data/lib/gitbroker/lifecycle.rb +114 -0
- data/lib/gitbroker/local_relay.rb +100 -0
- data/lib/gitbroker/login.rb +61 -0
- data/lib/gitbroker/neutralizer.rb +80 -0
- data/lib/gitbroker/process_scanner.rb +103 -0
- data/lib/gitbroker/repo_scanner.rb +50 -0
- data/lib/gitbroker/run_script.rb +58 -0
- data/lib/gitbroker/runner.rb +41 -0
- data/lib/gitbroker/service_installer.rb +145 -0
- data/lib/gitbroker/skill_installer.rb +30 -0
- data/lib/gitbroker/task_files.rb +90 -0
- data/lib/gitbroker/task_runner.rb +258 -0
- data/lib/gitbroker/url_handler_installer.rb +87 -0
- data/lib/gitbroker/version.rb +5 -0
- data/lib/gitbroker/worktree.rb +93 -0
- data/lib/gitbroker.rb +39 -0
- data/plugin/.claude-plugin/plugin.json +5 -0
- data/plugin/skills/gitbroker/SKILL.md +36 -0
- data/plugin/skills/gitbroker-explain/SKILL.md +141 -0
- metadata +95 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "socket"
|
|
7
|
+
|
|
8
|
+
module Gitbroker
|
|
9
|
+
class CLI
|
|
10
|
+
HELP = <<~TEXT
|
|
11
|
+
Usage: gitbroker <command> [options]
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
login [--server URL] [--name NAME] Pair this machine with GitBroker (you type the code at /devices)
|
|
15
|
+
start Stay connected; run your agent when you ask from a PR card
|
|
16
|
+
launch Start the companion in the background if it is not running
|
|
17
|
+
stop Stop the companion (and its background service, so it stays stopped)
|
|
18
|
+
restart Restart the companion, e.g. after `gem update gitbroker`
|
|
19
|
+
install-service Run `gitbroker start` in the background (launchd / systemd --user), and let
|
|
20
|
+
the website's "Start companion" button wake it (gitbroker:// handler)
|
|
21
|
+
install-skill Install the gitbroker skills into ~/.claude/skills
|
|
22
|
+
status Show pairing, configuration and the repositories found
|
|
23
|
+
clean Remove finished agent worktrees (keeps ones with uncommitted work)
|
|
24
|
+
install-hooks [--yes] Show your own Claude Code sessions on your cards (asks first)
|
|
25
|
+
uninstall-hooks Remove those hooks
|
|
26
|
+
help Show this help
|
|
27
|
+
|
|
28
|
+
Config: ~/.config/gitbroker/config.yml (server, roots, scan_depth, terminal, worktree_root, agent,
|
|
29
|
+
claude_args, codex_args). Your agent runs as you, with your own login and credentials.
|
|
30
|
+
Agent activity is visible only to you; only status is sent, never prompts, file contents or tool input.
|
|
31
|
+
TEXT
|
|
32
|
+
|
|
33
|
+
def initialize(argv, out: $stdout, err: $stderr, env: ENV, home: Dir.home, input: $stdin)
|
|
34
|
+
@argv = argv.dup
|
|
35
|
+
@out = out
|
|
36
|
+
@err = err
|
|
37
|
+
@env = env
|
|
38
|
+
@home = home
|
|
39
|
+
@input = input
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
command = @argv.shift || "help"
|
|
44
|
+
return task_ended if command == "task-ended"
|
|
45
|
+
return hook if command == "hook"
|
|
46
|
+
|
|
47
|
+
case command
|
|
48
|
+
when "login" then login
|
|
49
|
+
when "start" then start
|
|
50
|
+
when "launch" then launch
|
|
51
|
+
when "stop" then stop
|
|
52
|
+
when "restart" then restart
|
|
53
|
+
when "install-service" then install_service
|
|
54
|
+
when "install-skill" then install_skill
|
|
55
|
+
when "status" then status
|
|
56
|
+
when "clean" then clean
|
|
57
|
+
when "install-hooks" then install_hooks
|
|
58
|
+
when "uninstall-hooks" then uninstall_hooks
|
|
59
|
+
when "help", "-h", "--help" then @out.puts(HELP)
|
|
60
|
+
else
|
|
61
|
+
@err.puts "Unknown command: #{command}"
|
|
62
|
+
@err.puts HELP
|
|
63
|
+
return 1
|
|
64
|
+
end
|
|
65
|
+
0
|
|
66
|
+
rescue Error, OptionParser::ParseError => e
|
|
67
|
+
@err.puts "gitbroker: #{e.message}"
|
|
68
|
+
1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def config = @config ||= Config.load(dir: Config.default_dir(@env), env: @env)
|
|
74
|
+
|
|
75
|
+
def login
|
|
76
|
+
options = {}
|
|
77
|
+
OptionParser.new do |parser|
|
|
78
|
+
parser.on("--server URL") { options[:server] = _1 }
|
|
79
|
+
parser.on("--name NAME") { options[:name] = _1 }
|
|
80
|
+
end.parse!(@argv)
|
|
81
|
+
@config = config.save!("server" => options[:server]) if options[:server]
|
|
82
|
+
Login.new(config:, out: @out, name: options[:name] || Socket.gethostname).run
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def start
|
|
86
|
+
raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
|
|
87
|
+
|
|
88
|
+
@out.puts "GitBroker companion #{VERSION} → #{config.server} (agent: #{config.agent}, terminal: #{config.terminal})"
|
|
89
|
+
Daemon.build(config, out: @out).run
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def install_service
|
|
93
|
+
raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
|
|
94
|
+
|
|
95
|
+
path = ServiceInstaller.new(home: @home, env: @env).install!
|
|
96
|
+
@out.puts "Installed and started #{path}. Run this again after upgrading the gem."
|
|
97
|
+
handler = UrlHandlerInstaller.new(home: @home).install!
|
|
98
|
+
@out.puts "Registered #{handler} so the website's \"Start companion\" button can wake it."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The target of the website's gitbroker://start link and of `gitbroker launch` by hand: start the background
|
|
102
|
+
# service, or the daemon itself when none is installed. Safe to run when it is already running.
|
|
103
|
+
def launch
|
|
104
|
+
raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
|
|
105
|
+
lifecycle.launch
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def lifecycle = @lifecycle ||= Lifecycle.new(dir: config.dir, home: @home, service: ServiceInstaller.new(home: @home, env: @env))
|
|
109
|
+
|
|
110
|
+
# Works on this machine only. With a background service installed it is stopped through launchd / systemd, so it
|
|
111
|
+
# stays stopped until `gitbroker launch` (or the website's "Start companion" button).
|
|
112
|
+
def stop
|
|
113
|
+
@out.puts(lifecycle.stop ? "Stopped the companion." : "The companion is not running.")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def restart
|
|
117
|
+
raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
|
|
118
|
+
|
|
119
|
+
lifecycle.restart
|
|
120
|
+
@out.puts "Restarting the companion."
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def install_skill
|
|
124
|
+
SkillInstaller.new(home: @home, env: @env).install!.each { @out.puts "Installed the #{File.basename(_1)} skill to #{_1}." }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def status
|
|
128
|
+
@out.puts "GitBroker companion #{VERSION}"
|
|
129
|
+
@out.puts "Server: #{config.server}"
|
|
130
|
+
@out.puts "Paired: #{config.paired? ? "yes (device #{config.device_id})" : "no (run `gitbroker login`)"}"
|
|
131
|
+
@out.puts "Agent: #{config.agent}"
|
|
132
|
+
@out.puts "Terminal: #{config.terminal}"
|
|
133
|
+
@out.puts "Hooks: #{hooks_installer.installed? ? "installed" : "not installed (gitbroker install-hooks)"}"
|
|
134
|
+
@out.puts "Roots: #{config.roots.join(", ")}"
|
|
135
|
+
@out.puts "Worktrees: #{config.worktree_root}"
|
|
136
|
+
@out.puts "Task worktrees: #{Worktree.new(root: config.worktree_root).list.size} (running or kept; `gitbroker clean` removes finished ones)"
|
|
137
|
+
repositories = RepoScanner.new(roots: config.roots, depth: config.scan_depth, exclude: [ config.worktree_root ]).scan
|
|
138
|
+
@out.puts "Repositories found: #{repositories.size}"
|
|
139
|
+
repositories.each { @out.puts " #{_1["full_name"]} #{_1["path"]}" }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# `git worktree remove` without --force refuses a worktree with modified or untracked files, so work is never lost.
|
|
143
|
+
def clean
|
|
144
|
+
worktree = Worktree.new(root: config.worktree_root)
|
|
145
|
+
worktree.list.each do |dir|
|
|
146
|
+
git_dir = GitContext.git_dir(dir)
|
|
147
|
+
repo_path = git_dir && File.dirname(GitContext.common_dir(git_dir))
|
|
148
|
+
removed = repo_path && File.directory?(repo_path) && worktree.remove!(dir, repo_path:)
|
|
149
|
+
@out.puts(removed ? "Removed #{dir}" : "Kept #{dir} (uncommitted work)")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def hooks_installer = HooksInstaller.default(env: @env, home: @home)
|
|
154
|
+
|
|
155
|
+
# Called by Claude Code on every hook event: silent, always exit 0.
|
|
156
|
+
def hook
|
|
157
|
+
Hook.new(event: @argv.shift, input: @input, socket_path: File.join(Config.default_dir(@env), "daemon.sock"), env: @env).run
|
|
158
|
+
0
|
|
159
|
+
rescue StandardError
|
|
160
|
+
0
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def install_hooks
|
|
164
|
+
yes = @argv.delete("--yes")
|
|
165
|
+
installer = hooks_installer
|
|
166
|
+
@out.puts "This adds Claude Code command hooks (#{HooksInstaller::EVENTS.join(", ")}) to #{installer.settings_path}."
|
|
167
|
+
@out.puts "Each runs `gitbroker hook <event>`, which tells your running companion the session id, repository, branch " \
|
|
168
|
+
"and head SHA. It never sends prompts, file contents or tool input, and only you see the result."
|
|
169
|
+
unless yes
|
|
170
|
+
@out.print "Add the hooks? [y/N] "
|
|
171
|
+
return @out.puts("Nothing changed.") unless %w[y yes].include?(@input.gets.to_s.strip.downcase)
|
|
172
|
+
end
|
|
173
|
+
backup = installer.install!
|
|
174
|
+
@out.puts "Hooks installed#{" (previous settings saved to #{backup})" if backup}. New Claude Code sessions pick them up."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def uninstall_hooks
|
|
178
|
+
if hooks_installer.installed? || File.exist?(hooks_installer.settings_path)
|
|
179
|
+
backup = hooks_installer.uninstall!
|
|
180
|
+
@out.puts(backup ? "Hooks removed (previous settings saved to #{backup})." : "No GitBroker hooks found; nothing changed.")
|
|
181
|
+
else
|
|
182
|
+
@out.puts "No Claude Code settings file; nothing to remove."
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Called by the terminal run script after the agent exits: silent, always exit 0.
|
|
187
|
+
def task_ended
|
|
188
|
+
id = @env["GITBROKER_TASK_ID"].to_s
|
|
189
|
+
token = @env["GITBROKER_TASK_TOKEN"].to_s
|
|
190
|
+
return 0 if id.empty? || token.empty?
|
|
191
|
+
|
|
192
|
+
Api.new(server: @env["GITBROKER_SERVER"] || config.server, token:)
|
|
193
|
+
.post("/agent_tasks/#{id}/events", { hook_event_name: "TaskEnded", exit_status: @argv.first.to_s })
|
|
194
|
+
0
|
|
195
|
+
rescue StandardError # never break the user's terminal session over a status report
|
|
196
|
+
0
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Gitbroker
|
|
7
|
+
# ~/.config/gitbroker/config.yml (settings) and credentials.yml (0600, device token). Spec §4.1: roots, terminal,
|
|
8
|
+
# worktree location, preferred agent, and the agents' command-line args for autonomous runs (spec §4.2 step 5).
|
|
9
|
+
class Config
|
|
10
|
+
TERMINALS = %w[terminal iterm2 ghostty warp tmux].freeze
|
|
11
|
+
AGENTS = %w[claude codex].freeze
|
|
12
|
+
DEFAULT_SERVER = "https://git.broker"
|
|
13
|
+
DEFAULT_CLAUDE_ARGS = %w[--permission-mode auto --permission-prompts none].freeze
|
|
14
|
+
DEFAULT_CODEX_ARGS = %w[--sandbox workspace-write -c sandbox_workspace_write.network_access=true].freeze
|
|
15
|
+
# GitBroker sets these itself for every run; user args may not override them.
|
|
16
|
+
RESERVED_ARGS = %w[-p --print --output-format --mcp-config --strict-mcp-config --plugin-dir --json].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :dir, :data
|
|
19
|
+
|
|
20
|
+
def self.default_dir(env = ENV)
|
|
21
|
+
env["GITBROKER_CONFIG_DIR"] || File.join(env["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config"), "gitbroker")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.load(dir: default_dir, env: ENV, platform: Gitbroker.platform)
|
|
25
|
+
path = File.join(dir, "config.yml")
|
|
26
|
+
data = File.exist?(path) ? YAML.safe_load_file(path) || {} : {}
|
|
27
|
+
new(dir:, data:, env:, platform:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(dir:, data: {}, env: ENV, platform: Gitbroker.platform)
|
|
31
|
+
@dir = dir
|
|
32
|
+
@data = data.to_h.transform_keys(&:to_s)
|
|
33
|
+
@env = env
|
|
34
|
+
@platform = platform
|
|
35
|
+
validate!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def server = (@env["GITBROKER_SERVER"] || data["server"] || DEFAULT_SERVER).to_s.chomp("/")
|
|
39
|
+
def cable_url = "#{server.sub(/\Ahttp/, "ws")}/cable"
|
|
40
|
+
def roots = Array(data["roots"] || [ "~/Code" ]).map { File.expand_path(_1) }
|
|
41
|
+
def scan_depth = Integer(data["scan_depth"] || 3)
|
|
42
|
+
def terminal = (data["terminal"] || (@platform == "macos" ? "terminal" : "tmux")).to_s
|
|
43
|
+
def worktree_root = File.expand_path(data["worktree_root"] || "~/.local/share/gitbroker/worktrees")
|
|
44
|
+
def state_dir = File.expand_path(data["state_dir"] || "~/.local/state/gitbroker")
|
|
45
|
+
def agent = (data["agent"] || "claude").to_s
|
|
46
|
+
def claude_command = (data["claude_command"] || "claude").to_s
|
|
47
|
+
def codex_command = (data["codex_command"] || "codex").to_s
|
|
48
|
+
def claude_args = data.key?("claude_args") ? data["claude_args"] : DEFAULT_CLAUDE_ARGS
|
|
49
|
+
def codex_args = data.key?("codex_args") ? data["codex_args"] : DEFAULT_CODEX_ARGS
|
|
50
|
+
|
|
51
|
+
def credentials_path = File.join(dir, "credentials.yml")
|
|
52
|
+
def device_token = credentials["device_token"]
|
|
53
|
+
def device_id = credentials["device_id"]
|
|
54
|
+
def paired? = !device_token.to_s.empty?
|
|
55
|
+
|
|
56
|
+
def save!(updates)
|
|
57
|
+
merged = data.merge(updates.transform_keys(&:to_s)).compact
|
|
58
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
59
|
+
File.write(File.join(dir, "config.yml"), merged.to_yaml)
|
|
60
|
+
self.class.new(dir:, data: merged, env: @env, platform: @platform)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def save_credentials!(device_token:, device_id:)
|
|
64
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
65
|
+
File.write(credentials_path, { "device_token" => device_token, "device_id" => device_id, "server" => server }.to_yaml, perm: 0o600)
|
|
66
|
+
File.chmod(0o600, credentials_path)
|
|
67
|
+
@credentials = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def credentials
|
|
73
|
+
@credentials ||= File.exist?(credentials_path) ? YAML.safe_load_file(credentials_path) || {} : {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate!
|
|
77
|
+
raise Error, "Unknown terminal #{terminal.inspect}. Use one of: #{TERMINALS.join(", ")}" unless TERMINALS.include?(terminal)
|
|
78
|
+
raise Error, "Unknown agent #{agent.inspect}. Use one of: #{AGENTS.join(", ")}" unless AGENTS.include?(agent)
|
|
79
|
+
|
|
80
|
+
{ "claude_args" => claude_args, "codex_args" => codex_args }.each do |key, args|
|
|
81
|
+
raise Error, "#{key} must be a list of strings" unless args.is_a?(Array) && args.all?(String)
|
|
82
|
+
|
|
83
|
+
reserved = args.map { _1.split("=").first } & RESERVED_ARGS
|
|
84
|
+
raise Error, "#{key} may not set #{reserved.join(", ")}; GitBroker sets those itself" if reserved.any?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module Gitbroker
|
|
8
|
+
# `gitbroker start`: keep the device connected, report checkouts, claim and run tasks, and relay the status of
|
|
9
|
+
# agents the user started themselves (relay: Task 31, process scanner: Task 32).
|
|
10
|
+
class Daemon
|
|
11
|
+
RESCAN_EVERY = 300
|
|
12
|
+
MAX_BACKOFF = 60
|
|
13
|
+
BATCH = 50
|
|
14
|
+
# Spec §5.2 / main §18.4: nothing else about an observed agent ever leaves the machine.
|
|
15
|
+
OBSERVATION_FIELDS = %w[event agent external_session_id repository branch head_sha occurred_at].freeze
|
|
16
|
+
|
|
17
|
+
def self.build(config, out: $stdout, relay: LocalRelay.new(path: File.join(config.dir, "daemon.sock")),
|
|
18
|
+
process_scanner: build_process_scanner(config))
|
|
19
|
+
raise Error, "This machine is not paired. Run `gitbroker login` first." unless config.paired?
|
|
20
|
+
|
|
21
|
+
new(scanner: RepoScanner.new(roots: config.roots, depth: config.scan_depth, exclude: [ config.worktree_root ]),
|
|
22
|
+
client_factory: -> { CableClient.new(url: config.cable_url, token: config.device_token, origin: config.server) },
|
|
23
|
+
task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out:, relay:, process_scanner:,
|
|
24
|
+
lifecycle: Lifecycle.new(dir: config.dir))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Claude sessions with hooks installed report real working/waiting status, so the scanner only looks for `claude`
|
|
28
|
+
# without hooks (checked on every scan, so installing hooks takes effect without a restart). GitBroker's own
|
|
29
|
+
# worktrees are skipped: those agents are tasks, reported by the task.
|
|
30
|
+
def self.build_process_scanner(config)
|
|
31
|
+
hooks = HooksInstaller.default
|
|
32
|
+
skip_claude = lambda do
|
|
33
|
+
hooks.installed?
|
|
34
|
+
rescue Error
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
ProcessScanner.new(skip_claude:, exclude: [ config.worktree_root ])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(scanner:, client_factory:, task_runner_factory:, out: $stdout, sleeper: ->(seconds) { sleep(seconds) },
|
|
41
|
+
clock: -> { Time.now }, spawner: ->(&block) { Thread.new(&block) }, relay: nil, process_scanner: nil, lifecycle: nil)
|
|
42
|
+
@scanner = scanner
|
|
43
|
+
@client_factory = client_factory
|
|
44
|
+
@task_runner = task_runner_factory.call(method(:report))
|
|
45
|
+
@out = out
|
|
46
|
+
@sleeper = sleeper
|
|
47
|
+
@clock = clock
|
|
48
|
+
@spawner = spawner
|
|
49
|
+
@relay = relay
|
|
50
|
+
@process_scanner = process_scanner
|
|
51
|
+
@lifecycle = lifecycle
|
|
52
|
+
@repositories = []
|
|
53
|
+
@running = Set.new
|
|
54
|
+
@running_lock = Mutex.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run
|
|
58
|
+
@backoff = 1
|
|
59
|
+
@relay&.start
|
|
60
|
+
@lifecycle&.write_pid
|
|
61
|
+
until @stopping
|
|
62
|
+
begin
|
|
63
|
+
@client = @client_factory.call
|
|
64
|
+
wire(@client)
|
|
65
|
+
@client.run
|
|
66
|
+
rescue CableClient::Unauthorized
|
|
67
|
+
raise Error, "GitBroker rejected this device (revoked?). Run `gitbroker login` to pair again."
|
|
68
|
+
rescue Error, SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
|
|
69
|
+
@out.puts "Connection problem: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
break if @stopping
|
|
72
|
+
|
|
73
|
+
@sleeper.call(@backoff)
|
|
74
|
+
@backoff = [ @backoff * 2, MAX_BACKOFF ].min
|
|
75
|
+
end
|
|
76
|
+
ensure
|
|
77
|
+
@relay&.stop
|
|
78
|
+
@lifecycle&.clear_pid
|
|
79
|
+
# Only now, with the connection closed, so two daemons never share the device.
|
|
80
|
+
@lifecycle.spawn_daemon if @respawn && @lifecycle
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stop
|
|
84
|
+
@stopping = true
|
|
85
|
+
@client&.stop
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Task runners report progress and results through whatever connection is live. A report sent while the device
|
|
89
|
+
# is reconnecting is lost; the server's sweep settles the task.
|
|
90
|
+
def report(action, data) = @client&.perform(action, data)
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def wire(client)
|
|
95
|
+
client.on_subscribed do
|
|
96
|
+
@backoff = 1
|
|
97
|
+
rescan(client)
|
|
98
|
+
@out.puts "Connected. #{@repositories.size} repositories reported."
|
|
99
|
+
end
|
|
100
|
+
client.on_tick do
|
|
101
|
+
client.perform("heartbeat", "version" => VERSION)
|
|
102
|
+
rescan(client) if @scanned_at.nil? || @clock.call - @scanned_at >= RESCAN_EVERY
|
|
103
|
+
observe(client, @process_scanner.scan) if @process_scanner
|
|
104
|
+
end
|
|
105
|
+
client.on_poll { observe(client, @relay.drain) if @relay }
|
|
106
|
+
client.on_message { |message| handle(client, message) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The server replaces its list with each report, so every report carries the full list.
|
|
110
|
+
def rescan(client)
|
|
111
|
+
@repositories = @scanner.scan
|
|
112
|
+
@scanned_at = @clock.call
|
|
113
|
+
client.perform("repositories", "repositories" => @repositories)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def observe(client, events)
|
|
117
|
+
clean = Array(events).filter_map { _1.is_a?(Hash) ? _1.slice(*OBSERVATION_FIELDS) : nil }
|
|
118
|
+
clean.each_slice(BATCH) { client.perform("observe", "events" => _1) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The website's Restart / Stop buttons (Device#control!). Nothing else about the message is trusted or used.
|
|
122
|
+
def control(action)
|
|
123
|
+
return unless @lifecycle
|
|
124
|
+
|
|
125
|
+
case action
|
|
126
|
+
when "stop"
|
|
127
|
+
@out.puts "Stopping (asked from GitBroker)."
|
|
128
|
+
@lifecycle.stop_service
|
|
129
|
+
stop
|
|
130
|
+
when "restart"
|
|
131
|
+
@out.puts "Restarting (asked from GitBroker)."
|
|
132
|
+
# With a service the manager restarts us (it may kill us mid-call); otherwise respawn once the run loop has ended.
|
|
133
|
+
return if @lifecycle.restart_service
|
|
134
|
+
|
|
135
|
+
@respawn = true
|
|
136
|
+
stop
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def handle(client, message)
|
|
141
|
+
return control(message["action"]) if message["type"] == "control"
|
|
142
|
+
|
|
143
|
+
id = message["task_id"]
|
|
144
|
+
return unless id.is_a?(String)
|
|
145
|
+
|
|
146
|
+
case message["type"]
|
|
147
|
+
when "task_available" then client.perform("claim", "task_id" => id)
|
|
148
|
+
when "cancel" then @task_runner.cancel(id)
|
|
149
|
+
when "task" then start_task(id, message)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def start_task(id, message)
|
|
154
|
+
return unless @running_lock.synchronize { @running.add?(id) }
|
|
155
|
+
|
|
156
|
+
repositories = @repositories
|
|
157
|
+
@spawner.call do
|
|
158
|
+
@task_runner.run(message, repositories:)
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
@out.puts "Task #{id} crashed: #{e.class}: #{e.message}"
|
|
161
|
+
ensure
|
|
162
|
+
@running_lock.synchronize { @running.delete(id) }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitbroker
|
|
4
|
+
# Understanding spec §6.2: after an explain run finishes writing, a second agent run in a fresh context checks every
|
|
5
|
+
# walkthrough step, summary, intuition claim and quiz answer against the code, and sends verify_explanation.
|
|
6
|
+
module ExplainVerifier
|
|
7
|
+
BRIEF = /^Run brief: .+$/
|
|
8
|
+
|
|
9
|
+
def self.prompt(task)
|
|
10
|
+
brief = task.fetch("prompt")[BRIEF] or raise Error, "The explain prompt has no run brief."
|
|
11
|
+
<<~PROMPT
|
|
12
|
+
Use the gitbroker-explain skill in verify mode. Someone else wrote this explanation; check it against the code with fresh eyes.
|
|
13
|
+
#{brief}
|
|
14
|
+
Read it with show_explanation (pass pull_request and explanation_id from the run brief), read the code each block refers to in
|
|
15
|
+
this worktree, and send every verdict in one verify_explanation call. Change nothing else.
|
|
16
|
+
PROMPT
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# One `finished` for the task: the costs add up. A verifier that failed makes the task failed (the server then keeps the
|
|
20
|
+
# explanation "not verified"); one that was cancelled is cancelled.
|
|
21
|
+
def self.combine(write, verify)
|
|
22
|
+
costs = [ write.cost_usd, verify.cost_usd ].compact
|
|
23
|
+
status, code = case verify.status
|
|
24
|
+
when "done" then [ "done", nil ]
|
|
25
|
+
when "cancelled" then [ "cancelled", "cancelled" ]
|
|
26
|
+
else [ "failed", verify.error_code || "agent_failed" ]
|
|
27
|
+
end
|
|
28
|
+
TaskRunner::Outcome.new(status, costs.empty? ? nil : costs.sum.round(6), code, verify.last_output || write.last_output)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitbroker
|
|
4
|
+
# Spec §3.4: agents write to GitHub with the user's own gh only when gh's active github.com account is the
|
|
5
|
+
# GitBroker connection's login; otherwise through GitBroker's MCP write tools. Both write as the user.
|
|
6
|
+
class GhCheck
|
|
7
|
+
LOGIN = /Logged in to github\.com (?:account |as )([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/
|
|
8
|
+
|
|
9
|
+
def initialize(runner: Runner.new)
|
|
10
|
+
@runner = runner
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def matches?(login)
|
|
14
|
+
return false if login.to_s.empty?
|
|
15
|
+
|
|
16
|
+
output = @runner.capture("gh", "auth", "status", "--active", "--hostname", "github.com")
|
|
17
|
+
output = @runner.capture("gh", "auth", "status", "--hostname", "github.com") if output.include?("unknown flag")
|
|
18
|
+
logins = output.scan(LOGIN).flatten.uniq
|
|
19
|
+
logins.one? && logins.first.casecmp?(login.to_s)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitbroker
|
|
4
|
+
# (owner/repo, branch, head SHA) for a working directory, read from .git files. The companion never runs git in
|
|
5
|
+
# directories it merely scans or that a hook/process reports, so a repository's config can never execute anything.
|
|
6
|
+
module GitContext
|
|
7
|
+
SHA = /\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/
|
|
8
|
+
MAX_DEPTH = 64
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def resolve(cwd)
|
|
13
|
+
return nil if cwd.to_s.empty? || !File.directory?(cwd)
|
|
14
|
+
|
|
15
|
+
root = enclosing_checkout(File.expand_path(cwd)) or return nil
|
|
16
|
+
git_dir = git_dir(root) or return nil
|
|
17
|
+
repository = repository_from(remote_urls_in(common_dir(git_dir))) or return nil
|
|
18
|
+
branch, head_sha = head(git_dir)
|
|
19
|
+
{ "repository" => repository, "branch" => branch, "head_sha" => head_sha }
|
|
20
|
+
rescue SystemCallError, IOError
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def git_dir(dir)
|
|
25
|
+
dot_git = File.join(dir, ".git")
|
|
26
|
+
return dot_git if File.directory?(dot_git)
|
|
27
|
+
return nil unless File.file?(dot_git)
|
|
28
|
+
|
|
29
|
+
target = File.read(dot_git, 4_096)[/\Agitdir: (.+)$/, 1]
|
|
30
|
+
target && File.expand_path(target.strip, dir)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def common_dir(git_dir)
|
|
34
|
+
file = File.join(git_dir, "commondir")
|
|
35
|
+
File.file?(file) ? File.expand_path(File.read(file, 4_096).strip, git_dir) : git_dir
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remote_urls(dir)
|
|
39
|
+
git_dir = git_dir(dir)
|
|
40
|
+
git_dir ? remote_urls_in(common_dir(git_dir)) : []
|
|
41
|
+
rescue SystemCallError, IOError
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Minimal git-config reader: [remote "name"] sections, url = … keys.
|
|
46
|
+
def remote_urls_in(common)
|
|
47
|
+
path = File.join(common, "config")
|
|
48
|
+
return [] unless File.file?(path)
|
|
49
|
+
|
|
50
|
+
remote = nil
|
|
51
|
+
File.foreach(path).filter_map do |line|
|
|
52
|
+
line = line.strip
|
|
53
|
+
if (section = line.match(/\A\[\s*remote\s+"([^"]+)"\s*\]\z/))
|
|
54
|
+
remote = section[1]
|
|
55
|
+
nil
|
|
56
|
+
elsif line.start_with?("[")
|
|
57
|
+
remote = nil
|
|
58
|
+
elsif remote && (url = line.match(/\Aurl\s*=\s*(.+)\z/))
|
|
59
|
+
[ remote, url[1].strip.delete_prefix('"').delete_suffix('"') ]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def repository_from(remotes)
|
|
65
|
+
remotes.sort_by { |name, _url| name == "origin" ? 0 : 1 }.lazy.filter_map { |_name, url| RepoScanner.full_name_from(url) }.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def head(git_dir)
|
|
69
|
+
head = File.read(File.join(git_dir, "HEAD"), 1_024).strip
|
|
70
|
+
return [ nil, (head if head.match?(SHA)) ] unless head.start_with?("ref: ")
|
|
71
|
+
|
|
72
|
+
ref = head.delete_prefix("ref: ")
|
|
73
|
+
[ ref.start_with?("refs/heads/") ? ref.delete_prefix("refs/heads/") : nil, read_ref(git_dir, ref) ]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_ref(git_dir, ref)
|
|
77
|
+
return nil if ref.include?("..")
|
|
78
|
+
|
|
79
|
+
[ git_dir, common_dir(git_dir) ].uniq.each do |dir|
|
|
80
|
+
loose = File.join(dir, ref)
|
|
81
|
+
return File.read(loose, 128).strip if File.file?(loose)
|
|
82
|
+
end
|
|
83
|
+
packed = File.join(common_dir(git_dir), "packed-refs")
|
|
84
|
+
return nil unless File.file?(packed)
|
|
85
|
+
|
|
86
|
+
File.foreach(packed) do |line|
|
|
87
|
+
sha, name = line.strip.split(" ", 2)
|
|
88
|
+
return sha if name == ref && sha.match?(SHA)
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def enclosing_checkout(dir)
|
|
94
|
+
MAX_DEPTH.times do
|
|
95
|
+
return dir if File.exist?(File.join(dir, ".git"))
|
|
96
|
+
|
|
97
|
+
parent = File.dirname(dir)
|
|
98
|
+
return nil if parent == dir
|
|
99
|
+
|
|
100
|
+
dir = parent
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Gitbroker
|
|
7
|
+
# `gitbroker hook <Event>`, run by Claude Code's user-level command hooks. It must never slow down or break Claude
|
|
8
|
+
# Code: it prints nothing, never raises, runs no subprocess, and skips everything when the daemon is not running or
|
|
9
|
+
# the session is a GitBroker task (reported through the task itself). It forwards status only; the prompt is never
|
|
10
|
+
# read.
|
|
11
|
+
class Hook
|
|
12
|
+
EVENTS = { "SessionStart" => "start", "UserPromptSubmit" => "prompt", "Stop" => "stop", "SessionEnd" => "end" }.freeze
|
|
13
|
+
TIMEOUT = 0.2
|
|
14
|
+
MAX_INPUT = 1_000_000
|
|
15
|
+
|
|
16
|
+
def initialize(event:, input:, socket_path:, env: ENV, clock: -> { Time.now })
|
|
17
|
+
@event = event.to_s
|
|
18
|
+
@input = input
|
|
19
|
+
@socket_path = socket_path
|
|
20
|
+
@env = env
|
|
21
|
+
@clock = clock
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
observation = build
|
|
26
|
+
LocalRelay.send_line(@socket_path, JSON.generate(observation), timeout: TIMEOUT) if observation
|
|
27
|
+
nil
|
|
28
|
+
rescue StandardError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build
|
|
35
|
+
kind = EVENTS[@event]
|
|
36
|
+
return nil unless kind && File.socket?(@socket_path)
|
|
37
|
+
return nil unless @env["GITBROKER_TASK_ID"].to_s.empty?
|
|
38
|
+
|
|
39
|
+
data = JSON.parse(@input.read(MAX_INPUT).to_s)
|
|
40
|
+
return nil unless data.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
session_id = data["session_id"].to_s
|
|
43
|
+
return nil if session_id.empty?
|
|
44
|
+
|
|
45
|
+
context = GitContext.resolve(data["cwd"].to_s) || {} # files only; never runs git in the hook's cwd
|
|
46
|
+
{ "event" => kind, "agent" => "claude_code", "external_session_id" => session_id,
|
|
47
|
+
"occurred_at" => @clock.call.utc.iso8601 }.merge(context)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|