diffbroker 0.16.0 → 0.17.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 +4 -4
- data/lib/diffbroker/agents/claude.rb +1 -5
- data/lib/diffbroker/agents/codex.rb +0 -2
- data/lib/diffbroker/agents.rb +1 -1
- data/lib/diffbroker/cli.rb +2 -37
- data/lib/diffbroker/config.rb +1 -4
- data/lib/diffbroker/task_files.rb +9 -59
- data/lib/diffbroker/task_runner.rb +17 -54
- data/lib/diffbroker/version.rb +1 -1
- data/lib/diffbroker.rb +0 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- metadata +2 -4
- data/lib/diffbroker/launcher.rb +0 -62
- data/lib/diffbroker/run_script.rb +0 -62
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0301a413f14ac1a2d735cc4e7fd952d1d896117d1ef3e3e013508c38d4d1226c
|
|
4
|
+
data.tar.gz: f4bb42f5d0f1aff5bf1301143f34664d34b98e57c601999b2d8e5a07cf74a745
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d38cc871bedc4af0fed78b15ae74a31c4bf4c7ba4e53117596db9ef3927d7aa709c0c1216db8c648ab24cae3a07253acfef6b1ca30978f5b61c1711d47eeea7e
|
|
7
|
+
data.tar.gz: 712a68a04f52259ddf152f714eb15c1655f66721306a7bc91c18e7e7ff5569028dc771ecbb91b61f076242333aae073dc2500fa7d3c16a2deb5aaf86dee1aa5c
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Diffbroker
|
|
4
4
|
module Agents
|
|
5
5
|
# Claude Code. Autonomous: `claude -p … --output-format stream-json --verbose` (spec §4.2 step 5); the programmatic
|
|
6
|
-
# credit pool pays for it (spec §2).
|
|
6
|
+
# credit pool pays for it (spec §2).
|
|
7
7
|
class Claude < Base
|
|
8
8
|
PLUGIN_DIR = File.join(Diffbroker::ROOT, "plugin")
|
|
9
9
|
# A bare `Read` in --allowedTools means every file on the machine, so a prompt-injected agent could read
|
|
@@ -43,10 +43,6 @@ module Diffbroker
|
|
|
43
43
|
# The permissions above are passed again: a resumed session keeps its conversation, not its sandbox.
|
|
44
44
|
def resume(task) = task["resume_session_id"] ? [ "--resume", task["resume_session_id"] ] : []
|
|
45
45
|
|
|
46
|
-
def terminal_command(task, mcp_config:)
|
|
47
|
-
[ @config.claude_command, "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
|
|
48
|
-
end
|
|
49
|
-
|
|
50
46
|
def parse_stream(line)
|
|
51
47
|
event = parse_json(line) or return nil
|
|
52
48
|
|
|
@@ -49,8 +49,6 @@ module Diffbroker
|
|
|
49
49
|
[ @config.codex_command, "exec", "--json", *args, *mcp_overrides(mcp_config), task.fetch("prompt") ]
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
def terminal_command(_task, mcp_config:) = [ @config.codex_command, *mcp_overrides(mcp_config) ]
|
|
53
|
-
|
|
54
52
|
def parse_stream(line)
|
|
55
53
|
event = parse_json(line) or return nil
|
|
56
54
|
|
data/lib/diffbroker/agents.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Diffbroker
|
|
4
|
-
# The user's own coding agents, run headless
|
|
4
|
+
# The user's own coding agents, run headless. Spec §4.2 steps 5–7.
|
|
5
5
|
module Agents
|
|
6
6
|
Progress = Data.define(:step, :session_id)
|
|
7
7
|
# billing: "subscription" or "api" when the agent's login says how the run was paid, else nil.
|
data/lib/diffbroker/cli.rb
CHANGED
|
@@ -31,7 +31,7 @@ module Diffbroker
|
|
|
31
31
|
uninstall-hooks Remove those hooks
|
|
32
32
|
help Show this help
|
|
33
33
|
|
|
34
|
-
Config: ~/.config/diffbroker/config.yml (server, roots, scan_depth,
|
|
34
|
+
Config: ~/.config/diffbroker/config.yml (server, roots, scan_depth, worktree_root, agent,
|
|
35
35
|
claude_args, codex_args). Your agent runs as you, with your own login and credentials.
|
|
36
36
|
Agent activity is visible only to you; only status is sent, never prompts, file contents or tool input.
|
|
37
37
|
TEXT
|
|
@@ -52,8 +52,6 @@ module Diffbroker
|
|
|
52
52
|
|
|
53
53
|
def run
|
|
54
54
|
command = @argv.shift || "help"
|
|
55
|
-
return task_ended if command == "task-ended"
|
|
56
|
-
return publish_branch if command == "publish-branch"
|
|
57
55
|
return hook if command == "hook"
|
|
58
56
|
|
|
59
57
|
case command
|
|
@@ -136,7 +134,7 @@ module Diffbroker
|
|
|
136
134
|
def run_daemon
|
|
137
135
|
raise Error, "This machine is not paired. Run `diffbroker start` in a terminal to pair it." unless config.paired?
|
|
138
136
|
|
|
139
|
-
@out.puts "Diff Broker companion #{VERSION} → #{config.server} (agent: #{config.agent}
|
|
137
|
+
@out.puts "Diff Broker companion #{VERSION} → #{config.server} (agent: #{config.agent})"
|
|
140
138
|
heal_service_definition
|
|
141
139
|
Daemon.build(config, out: @out).run
|
|
142
140
|
rescue Daemon::Rejected
|
|
@@ -297,7 +295,6 @@ module Diffbroker
|
|
|
297
295
|
@out.puts "Server: #{config.server}"
|
|
298
296
|
@out.puts "Paired: #{config.paired? ? "yes (device #{config.device_id})" : "no (run `diffbroker start`)"}"
|
|
299
297
|
@out.puts "Agent: #{config.agent}"
|
|
300
|
-
@out.puts "Terminal: #{config.terminal}"
|
|
301
298
|
daemon_status
|
|
302
299
|
@out.puts "Hooks: #{hooks_installer.installed? ? "installed" : "not installed (diffbroker install-hooks)"}"
|
|
303
300
|
@out.puts "Roots: #{config.roots.join(", ")} (plus the projects Claude Code and Codex know)"
|
|
@@ -386,37 +383,5 @@ module Diffbroker
|
|
|
386
383
|
@out.puts "No Claude Code settings file; nothing to remove."
|
|
387
384
|
end
|
|
388
385
|
end
|
|
389
|
-
|
|
390
|
-
# Called by the terminal run script after an update_branch agent exited successfully (not listed in the help: the
|
|
391
|
-
# companion's own script is the only caller): pushes the finished rebase (BranchPublisher). Exit 1 when it could not.
|
|
392
|
-
def publish_branch
|
|
393
|
-
options = {}
|
|
394
|
-
OptionParser.new do |parser|
|
|
395
|
-
parser.on("--dir DIR") { options[:dir] = _1 }
|
|
396
|
-
parser.on("--head-ref REF") { options[:head_ref] = _1 }
|
|
397
|
-
parser.on("--expected SHA") { options[:expected_sha] = _1 }
|
|
398
|
-
parser.on("--base REF") { options[:base_ref] = _1 }
|
|
399
|
-
parser.on("--remote NAME") { options[:remote] = _1 }
|
|
400
|
-
end.parse!(@argv)
|
|
401
|
-
result = BranchPublisher.new(**options).publish!
|
|
402
|
-
@out.puts result.message
|
|
403
|
-
result.failed? ? 1 : 0
|
|
404
|
-
rescue ArgumentError => e
|
|
405
|
-
@err.puts "diffbroker: #{e.message}"
|
|
406
|
-
1
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
# Called by the terminal run script after the agent exits: silent, always exit 0.
|
|
410
|
-
def task_ended
|
|
411
|
-
id = @env["DIFFBROKER_TASK_ID"].to_s
|
|
412
|
-
token = @env["DIFFBROKER_TASK_TOKEN"].to_s
|
|
413
|
-
return 0 if id.empty? || token.empty?
|
|
414
|
-
|
|
415
|
-
Api.new(server: @env["DIFFBROKER_SERVER"] || config.server, token:)
|
|
416
|
-
.post("/agent_tasks/#{id}/events", { hook_event_name: "TaskEnded", exit_status: @argv.first.to_s })
|
|
417
|
-
0
|
|
418
|
-
rescue StandardError # never break the user's terminal session over a status report
|
|
419
|
-
0
|
|
420
|
-
end
|
|
421
386
|
end
|
|
422
387
|
end
|
data/lib/diffbroker/config.rb
CHANGED
|
@@ -4,10 +4,9 @@ require "yaml"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
6
|
module Diffbroker
|
|
7
|
-
# ~/.config/diffbroker/config.yml (settings) and credentials.yml (0600, device token). Spec §4.1: roots,
|
|
7
|
+
# ~/.config/diffbroker/config.yml (settings) and credentials.yml (0600, device token). Spec §4.1: roots,
|
|
8
8
|
# worktree location, preferred agent, and the agents' command-line args for autonomous runs (spec §4.2 step 5).
|
|
9
9
|
class Config
|
|
10
|
-
TERMINALS = %w[terminal iterm2 ghostty warp tmux].freeze
|
|
11
10
|
AGENTS = %w[claude codex].freeze
|
|
12
11
|
DEFAULT_SERVER = "https://diff.broker"
|
|
13
12
|
DEFAULT_CLAUDE_ARGS = %w[--permission-mode auto --permission-prompts none].freeze
|
|
@@ -71,7 +70,6 @@ module Diffbroker
|
|
|
71
70
|
ignored: ignored_roots, hints: AgentProjects.paths(home:), home:)
|
|
72
71
|
end
|
|
73
72
|
def scan_depth = Integer(data["scan_depth"] || 3)
|
|
74
|
-
def terminal = (data["terminal"] || (@platform == "macos" ? "terminal" : "tmux")).to_s
|
|
75
73
|
def worktree_root = File.expand_path(data["worktree_root"] || File.join(home, ".local/share/diffbroker/worktrees"))
|
|
76
74
|
def state_dir = File.expand_path(data["state_dir"] || "~/.local/state/diffbroker")
|
|
77
75
|
def agent = (data["agent"] || "claude").to_s
|
|
@@ -112,7 +110,6 @@ module Diffbroker
|
|
|
112
110
|
end
|
|
113
111
|
|
|
114
112
|
def validate!
|
|
115
|
-
raise Error, "Unknown terminal #{terminal.inspect}. Use one of: #{TERMINALS.join(", ")}" unless TERMINALS.include?(terminal)
|
|
116
113
|
raise Error, "Unknown agent #{agent.inspect}. Use one of: #{AGENTS.join(", ")}" unless AGENTS.include?(agent)
|
|
117
114
|
|
|
118
115
|
{ "claude_args" => claude_args, "codex_args" => codex_args }.each do |key, args|
|
|
@@ -8,21 +8,21 @@ module Diffbroker
|
|
|
8
8
|
# process environment. Spec §4.2 step 4: the Diff Broker MCP config lives outside the repository.
|
|
9
9
|
class TaskFiles
|
|
10
10
|
TOKEN_ENV = "DIFFBROKER_TASK_TOKEN"
|
|
11
|
-
|
|
12
|
-
SETTINGS_PATH = File.join(".claude", "settings.local.json")
|
|
11
|
+
TASK_ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
13
12
|
|
|
14
|
-
|
|
13
|
+
# The private directory of one task under the state dir (the MCP config lives there, outside the repository).
|
|
14
|
+
def self.task_dir(state_dir, task_id)
|
|
15
|
+
raise Error, "Invalid task id #{task_id.inspect}" unless task_id.to_s.match?(TASK_ID)
|
|
16
|
+
|
|
17
|
+
File.join(state_dir, "tasks", task_id)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(task_dir:, server:)
|
|
15
21
|
@task_dir = task_dir
|
|
16
|
-
@worktree = worktree
|
|
17
22
|
@server = server.to_s.chomp("/")
|
|
18
|
-
raise Error, "Invalid task id #{task_id.inspect}" unless task_id.to_s.match?(RunScript::TASK_ID)
|
|
19
|
-
|
|
20
|
-
@task_id = task_id
|
|
21
|
-
@runner = runner
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def mcp_url = "#{@server}/mcp/messages"
|
|
25
|
-
def events_url = "#{@server}/agent_tasks/#{@task_id}/events"
|
|
26
26
|
|
|
27
27
|
def mcp_config!
|
|
28
28
|
FileUtils.mkdir_p(@task_dir, mode: 0o700)
|
|
@@ -35,56 +35,6 @@ module Diffbroker
|
|
|
35
35
|
path
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Terminal mode with Claude Code (spec §4.2 step 6): HTTP hooks report start, stop and end.
|
|
39
|
-
def claude_hooks!
|
|
40
|
-
if @runner.git_success?(@worktree, "ls-files", "--error-unmatch", SETTINGS_PATH)
|
|
41
|
-
raise Error, "The repository tracks #{SETTINGS_PATH}; Diff Broker will not edit tracked files."
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
path = File.join(@worktree, SETTINGS_PATH)
|
|
45
|
-
if [ File.dirname(path), path ].any? { File.symlink?(_1) }
|
|
46
|
-
raise Error, "#{SETTINGS_PATH} in #{@worktree} is a symlink; Diff Broker will not write through it."
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
50
|
-
data = read_json(path)
|
|
51
|
-
existing = data["hooks"] || {}
|
|
52
|
-
raise Error, "#{path} has a \"hooks\" value that is not an object; fix it, then start the task again" unless existing.is_a?(Hash)
|
|
53
|
-
|
|
54
|
-
data["hooks"] = existing.merge(hooks) { |_event, theirs, ours| Array(theirs).reject { ours?(_1) } + ours }
|
|
55
|
-
File.write(path, "#{JSON.pretty_generate(data)}\n")
|
|
56
|
-
exclude_settings
|
|
57
|
-
end
|
|
58
|
-
|
|
59
38
|
def cleanup! = FileUtils.rm_rf(@task_dir)
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def hooks
|
|
64
|
-
hook = { "type" => "http", "url" => events_url, "headers" => { "Authorization" => "Bearer ${#{TOKEN_ENV}}" },
|
|
65
|
-
"allowedEnvVars" => [ TOKEN_ENV ], "timeout" => 10 }
|
|
66
|
-
HOOK_EVENTS.to_h { [ _1, [ { "hooks" => [ hook ] } ] ] }
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def ours?(group)
|
|
70
|
-
group.is_a?(Hash) && Array(group["hooks"]).any? { _1.is_a?(Hash) && _1["url"].to_s.start_with?("#{@server}/agent_tasks/") }
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def exclude_settings
|
|
74
|
-
exclude = File.expand_path(@runner.git(@worktree, "rev-parse", "--git-path", "info/exclude").strip, @worktree)
|
|
75
|
-
FileUtils.mkdir_p(File.dirname(exclude))
|
|
76
|
-
content = File.exist?(exclude) ? File.read(exclude) : ""
|
|
77
|
-
entry = "/#{SETTINGS_PATH}"
|
|
78
|
-
return if content.lines(chomp: true).include?(entry)
|
|
79
|
-
|
|
80
|
-
File.write(exclude, "#{content}#{"\n" unless content.empty? || content.end_with?("\n")}#{entry}\n")
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def read_json(path)
|
|
84
|
-
data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
85
|
-
data.is_a?(Hash) ? data : raise(JSON::ParserError)
|
|
86
|
-
rescue JSON::ParserError
|
|
87
|
-
raise Error, "#{path} is not valid JSON; fix or remove it, then start the task again"
|
|
88
|
-
end
|
|
89
39
|
end
|
|
90
40
|
end
|
|
@@ -4,7 +4,8 @@ require "fileutils"
|
|
|
4
4
|
|
|
5
5
|
module Diffbroker
|
|
6
6
|
# Runs one agent task (spec §4.2): worktree → neutralize (untrusted only) → gh or MCP prompt → MCP config →
|
|
7
|
-
# autonomous agent process
|
|
7
|
+
# autonomous agent process → progress and the final `finished` report. An untrusted head runs like any other, with
|
|
8
|
+
# the PR's own agent configuration set aside (Neutralizer).
|
|
8
9
|
class TaskRunner
|
|
9
10
|
CANCEL_GRACE = 10
|
|
10
11
|
PROGRESS_INTERVAL = 3
|
|
@@ -15,19 +16,16 @@ module Diffbroker
|
|
|
15
16
|
Outcome = Struct.new(:status, :cost_usd, :error_code, :last_output, :no_result, :billing)
|
|
16
17
|
|
|
17
18
|
def initialize(config:, report:, worktree: Worktree.new(root: config.worktree_root), neutralizer: Neutralizer.new,
|
|
18
|
-
gh_check: GhCheck.new,
|
|
19
|
-
progress_interval: PROGRESS_INTERVAL,
|
|
20
|
-
reaper: Reaper.new(root: config.worktree_root), sessions: SessionLedger.new(state_dir: config.state_dir))
|
|
19
|
+
gh_check: GhCheck.new, out: $stdout, grace: CANCEL_GRACE,
|
|
20
|
+
progress_interval: PROGRESS_INTERVAL, reaper: Reaper.new(root: config.worktree_root), sessions: SessionLedger.new(state_dir: config.state_dir))
|
|
21
21
|
@config = config
|
|
22
22
|
@report = report
|
|
23
23
|
@worktree = worktree
|
|
24
24
|
@neutralizer = neutralizer
|
|
25
25
|
@gh_check = gh_check
|
|
26
|
-
@launcher = launcher
|
|
27
26
|
@out = out
|
|
28
27
|
@grace = grace
|
|
29
28
|
@progress_interval = progress_interval
|
|
30
|
-
@run_script = run_script
|
|
31
29
|
@reaper = reaper
|
|
32
30
|
@sessions = sessions
|
|
33
31
|
@handles = {}
|
|
@@ -42,19 +40,6 @@ module Diffbroker
|
|
|
42
40
|
unless checkout
|
|
43
41
|
return finished(id, "failed", 0, "no_checkout", "No local checkout of #{task["repo"]} under #{@config.roots.join(", ")}")
|
|
44
42
|
end
|
|
45
|
-
# The server never offers this (spec §5.1); refuse anyway rather than run a stranger's code, or publish what an
|
|
46
|
-
# injected agent read, unattended.
|
|
47
|
-
if task["untrusted"] && (task["code_changing"] || task["terminal_only"]) && task["mode"] != "terminal"
|
|
48
|
-
return finished(id, "failed", 0, "untrusted_head", "This task runs only in a terminal on an untrusted head")
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Codex has no --strict-mcp-config equivalent: a headless run would load the user's own MCP servers next to a
|
|
52
|
-
# prompt-injected PR. Untrusted heads run Codex only in a terminal, where the user watches and approves.
|
|
53
|
-
if task["untrusted"] && task["mode"] != "terminal" && @config.agent == "codex"
|
|
54
|
-
return finished(id, "failed", 0, "untrusted_head",
|
|
55
|
-
"Codex does not run untrusted heads unattended (it would load your own MCP servers). Use Open in terminal, or set agent: claude.")
|
|
56
|
-
end
|
|
57
|
-
|
|
58
43
|
repo_path = checkout["path"]
|
|
59
44
|
dir = @worktree.prepare(repo_path:, full_name: task["repo"], number: task["pr_number"], head_sha: task["head_sha"],
|
|
60
45
|
kind: task["kind"], head_ref: task["head_ref"], branch: task["code_changing"] == true,
|
|
@@ -64,26 +49,23 @@ module Diffbroker
|
|
|
64
49
|
restored = task["untrusted"] ? @neutralizer.neutralize!(dir, repo_path:, number: task["pr_number"], base_ref: base_ref(task), remote: GitContext.remote_for(repo_path, task["repo"])) : []
|
|
65
50
|
task = task.merge("prompt" => prompt_for(task))
|
|
66
51
|
agent = Agents.for(@config, worktree: dir)
|
|
67
|
-
say(id, "#{task["kind"]} on #{task["repo"]}##{task["pr_number"]}: starting (#{agent.name}
|
|
52
|
+
say(id, "#{task["kind"]} on #{task["repo"]}##{task["pr_number"]}: starting (#{agent.name})")
|
|
68
53
|
say(id, "Worktree: #{dir}")
|
|
69
|
-
|
|
54
|
+
say(id, "Untrusted head: removed or restored from #{base_ref(task)}: #{restored.join(", ")}") if restored.any?
|
|
55
|
+
files = TaskFiles.new(task_dir: TaskFiles.task_dir(@config.state_dir, id), server: @config.server)
|
|
70
56
|
mcp_config = files.mcp_config!
|
|
71
57
|
|
|
72
|
-
|
|
73
|
-
|
|
58
|
+
begin
|
|
59
|
+
run_autonomous(task, agent, mcp_config, dir)
|
|
60
|
+
ensure
|
|
61
|
+
files.cleanup!
|
|
62
|
+
end
|
|
63
|
+
stopped = @reaper.reap!(dir)
|
|
64
|
+
say(id, "Stopped #{stopped} leftover process#{"es" unless stopped == 1} in #{dir}") if stopped.positive?
|
|
65
|
+
if @worktree.remove!(dir, repo_path:)
|
|
66
|
+
say(id, "Removed worktree #{dir}")
|
|
74
67
|
else
|
|
75
|
-
|
|
76
|
-
run_autonomous(task, agent, mcp_config, dir)
|
|
77
|
-
ensure
|
|
78
|
-
files.cleanup!
|
|
79
|
-
end
|
|
80
|
-
stopped = @reaper.reap!(dir)
|
|
81
|
-
say(id, "Stopped #{stopped} leftover process#{"es" unless stopped == 1} in #{dir}") if stopped.positive?
|
|
82
|
-
if @worktree.remove!(dir, repo_path:)
|
|
83
|
-
say(id, "Removed worktree #{dir}")
|
|
84
|
-
else
|
|
85
|
-
say(id, "Kept #{dir}: it has uncommitted work.")
|
|
86
|
-
end
|
|
68
|
+
say(id, "Kept #{dir}: it has uncommitted work.")
|
|
87
69
|
end
|
|
88
70
|
rescue Error, SystemCallError, KeyError => e
|
|
89
71
|
finished(id, "failed", 0, "agent_failed", e.message)
|
|
@@ -126,17 +108,6 @@ module Diffbroker
|
|
|
126
108
|
!task["read_only"] && !gh.empty? && @gh_check.matches?(task["github_login"]) ? gh : task.fetch("prompt")
|
|
127
109
|
end
|
|
128
110
|
|
|
129
|
-
def open_terminal(task, agent, files, mcp_config, dir, restored)
|
|
130
|
-
files.claude_hooks! if agent.name == "claude_code"
|
|
131
|
-
lines = [ ("Untrusted head: this PR's own code and tests run on your machine." if task["untrusted"]),
|
|
132
|
-
("Removed or restored from #{base_ref(task)}: #{restored.join(", ")}." if restored.any?) ].compact
|
|
133
|
-
script = @run_script.write!(task_id: task["task_id"], worktree: dir, token: task.fetch("token"), server: @config.server,
|
|
134
|
-
argv: agent.terminal_command(task, mcp_config:), prompt: task["prompt"],
|
|
135
|
-
notice: lines.empty? ? nil : lines.join("\n"), publish: publish_command(task, dir))
|
|
136
|
-
@launcher.launch!(script:, cwd: dir, title: "#{task["repo"].split("/").last.downcase}-#{task["pr_number"]}")
|
|
137
|
-
progress(task["task_id"], "opened in terminal", agent.name)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
111
|
# An explain or explain_change task gets a verifier pass in a fresh process after a successful write (understanding
|
|
141
112
|
# spec §6.2, activity spec §5). The server only accepts verify_explanation / verify_change_note once this process
|
|
142
113
|
# announces itself with progress phase "verifier".
|
|
@@ -187,14 +158,6 @@ module Diffbroker
|
|
|
187
158
|
Outcome.new("failed", outcome.cost_usd, "agent_failed", result.message, false, outcome.billing)
|
|
188
159
|
end
|
|
189
160
|
|
|
190
|
-
# The same push for a terminal run, executed by its script after the agent exits (`diffbroker publish-branch`).
|
|
191
|
-
def publish_command(task, dir)
|
|
192
|
-
return unless publishes_branch?(task)
|
|
193
|
-
|
|
194
|
-
[ RbConfig.ruby, ServiceInstaller.launcher, "publish-branch", "--dir", dir, "--head-ref", task["head_ref"].to_s,
|
|
195
|
-
"--expected", task["head_sha"].to_s, "--base", base_ref(task), "--remote", GitContext.remote_for(dir, task["repo"]) ]
|
|
196
|
-
end
|
|
197
|
-
|
|
198
161
|
def publishes_branch?(task) = task["kind"] == "update_branch" && task["cross_repository"] != true && !task["read_only"]
|
|
199
162
|
|
|
200
163
|
def verify_after_write(task, write, agent, mcp_config, dir)
|
data/lib/diffbroker/version.rb
CHANGED
data/lib/diffbroker.rb
CHANGED
|
@@ -29,7 +29,6 @@ module Diffbroker
|
|
|
29
29
|
autoload :Hook, File.expand_path("diffbroker/hook", __dir__)
|
|
30
30
|
autoload :HooksInstaller, File.expand_path("diffbroker/hooks_installer", __dir__)
|
|
31
31
|
autoload :Lifecycle, File.expand_path("diffbroker/lifecycle", __dir__)
|
|
32
|
-
autoload :Launcher, File.expand_path("diffbroker/launcher", __dir__)
|
|
33
32
|
autoload :Maintenance, File.expand_path("diffbroker/maintenance", __dir__)
|
|
34
33
|
autoload :LocalRelay, File.expand_path("diffbroker/local_relay", __dir__)
|
|
35
34
|
autoload :AccessHelp, File.expand_path("diffbroker/access_help", __dir__)
|
|
@@ -40,7 +39,6 @@ module Diffbroker
|
|
|
40
39
|
autoload :Reaper, File.expand_path("diffbroker/reaper", __dir__)
|
|
41
40
|
autoload :Reconciler, File.expand_path("diffbroker/reconciler", __dir__)
|
|
42
41
|
autoload :RepoScanner, File.expand_path("diffbroker/repo_scanner", __dir__)
|
|
43
|
-
autoload :RunScript, File.expand_path("diffbroker/run_script", __dir__)
|
|
44
42
|
autoload :Runner, File.expand_path("diffbroker/runner", __dir__)
|
|
45
43
|
autoload :ServiceInstaller, File.expand_path("diffbroker/service_installer", __dir__)
|
|
46
44
|
autoload :SessionLedger, File.expand_path("diffbroker/session_ledger", __dir__)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: diffbroker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuri Sidorov (@newstler)
|
|
@@ -53,7 +53,6 @@ files:
|
|
|
53
53
|
- lib/diffbroker/git_context.rb
|
|
54
54
|
- lib/diffbroker/hook.rb
|
|
55
55
|
- lib/diffbroker/hooks_installer.rb
|
|
56
|
-
- lib/diffbroker/launcher.rb
|
|
57
56
|
- lib/diffbroker/lifecycle.rb
|
|
58
57
|
- lib/diffbroker/local_relay.rb
|
|
59
58
|
- lib/diffbroker/login.rb
|
|
@@ -64,7 +63,6 @@ files:
|
|
|
64
63
|
- lib/diffbroker/reaper.rb
|
|
65
64
|
- lib/diffbroker/reconciler.rb
|
|
66
65
|
- lib/diffbroker/repo_scanner.rb
|
|
67
|
-
- lib/diffbroker/run_script.rb
|
|
68
66
|
- lib/diffbroker/runner.rb
|
|
69
67
|
- lib/diffbroker/service_installer.rb
|
|
70
68
|
- lib/diffbroker/session_ledger.rb
|
|
@@ -99,7 +97,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
99
97
|
- !ruby/object:Gem::Version
|
|
100
98
|
version: '0'
|
|
101
99
|
requirements: []
|
|
102
|
-
rubygems_version: 4.0.
|
|
100
|
+
rubygems_version: 4.0.20
|
|
103
101
|
specification_version: 4
|
|
104
102
|
summary: 'Diff Broker companion: runs your own Claude Code or Codex on your pull requests,
|
|
105
103
|
on this machine'
|
data/lib/diffbroker/launcher.rb
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
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
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "shellwords"
|
|
5
|
-
require "rbconfig"
|
|
6
|
-
|
|
7
|
-
module Diffbroker
|
|
8
|
-
# Terminal mode (spec §4.2 step 6): the script the terminal runs. The task token lives only here (0700, private
|
|
9
|
-
# state dir, deleted on start) and in the agent's environment. The prompt travels in an environment variable, so
|
|
10
|
-
# quoting never matters. When the agent exits, `diffbroker task-ended` tells Diff Broker.
|
|
11
|
-
class RunScript
|
|
12
|
-
TASK_ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
13
|
-
|
|
14
|
-
# RubyGems' stable wrapper (ServiceInstaller.launcher): a terminal task may outlive an upgrade's `gem cleanup`.
|
|
15
|
-
def initialize(state_dir:, ruby: RbConfig.ruby, exe: ServiceInstaller.launcher)
|
|
16
|
-
@state_dir = state_dir
|
|
17
|
-
@ruby = ruby
|
|
18
|
-
@exe = exe
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def task_dir(task_id)
|
|
22
|
-
raise Error, "Invalid task id #{task_id.inspect}" unless task_id.to_s.match?(TASK_ID)
|
|
23
|
-
|
|
24
|
-
File.join(@state_dir, "tasks", task_id)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# publish: a command (argv) to run in this script, outside the agent, once the agent exited successfully: the push of
|
|
28
|
-
# an update_branch rebase (BranchPublisher), which the agent's own hooks would refuse.
|
|
29
|
-
def write!(task_id:, worktree:, token:, server:, argv:, prompt:, notice: nil, publish: nil)
|
|
30
|
-
dir = task_dir(task_id)
|
|
31
|
-
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
32
|
-
File.chmod(0o700, dir)
|
|
33
|
-
path = File.join(dir, "run.sh")
|
|
34
|
-
FileUtils.rm_f(path)
|
|
35
|
-
File.write(path, render(task_id:, worktree:, token:, server:, argv:, prompt:, notice:, publish:), perm: 0o700)
|
|
36
|
-
File.chmod(0o700, path)
|
|
37
|
-
path
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
def render(task_id:, worktree:, token:, server:, argv:, prompt:, notice:, publish:)
|
|
43
|
-
inner = %(#{Shellwords.join(argv)} "$DIFFBROKER_PROMPT")
|
|
44
|
-
publish_step = publish ? "if [ \"$status\" = \"0\" ]; then #{Shellwords.join(publish)}; fi" : nil
|
|
45
|
-
<<~SH
|
|
46
|
-
#!/bin/bash
|
|
47
|
-
# Written by the Diff Broker companion for agent task #{task_id}. Deletes itself on start.
|
|
48
|
-
rm -f -- "$0"
|
|
49
|
-
#{"printf '%s\\n' #{Shellwords.escape(notice)}" if notice}
|
|
50
|
-
export DIFFBROKER_TASK_TOKEN=#{Shellwords.escape(token)}
|
|
51
|
-
export DIFFBROKER_TASK_ID=#{Shellwords.escape(task_id)}
|
|
52
|
-
export DIFFBROKER_SERVER=#{Shellwords.escape(server)}
|
|
53
|
-
export DIFFBROKER_PROMPT=#{Shellwords.escape(prompt)}
|
|
54
|
-
cd #{Shellwords.escape(worktree)} || exit 1
|
|
55
|
-
"${SHELL:-/bin/bash}" -lic #{Shellwords.escape(inner)}
|
|
56
|
-
status=$?
|
|
57
|
-
#{publish_step}
|
|
58
|
-
#{Shellwords.escape(@ruby)} #{Shellwords.escape(@exe)} task-ended "$status"
|
|
59
|
-
SH
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
end
|