diffbroker 0.13.1 → 0.15.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/access_help.rb +35 -0
- data/lib/diffbroker/agents/claude.rb +14 -2
- data/lib/diffbroker/agents/codex.rb +36 -2
- data/lib/diffbroker/agents.rb +4 -1
- data/lib/diffbroker/cli.rb +3 -1
- data/lib/diffbroker/daemon.rb +3 -1
- data/lib/diffbroker/explain_verifier.rb +2 -1
- data/lib/diffbroker/repo_scanner.rb +10 -1
- data/lib/diffbroker/task_runner.rb +13 -11
- data/lib/diffbroker/version.rb +1 -1
- data/lib/diffbroker.rb +1 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1d00b5898842a32e456087716d26bdb5b1c28e35f04e44b9d73acf8726d78f7
|
|
4
|
+
data.tar.gz: 25464398637a5bcf58c4e85f8babb57134cd1d3463e0325bb445b983328c7e09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 306e519a17b426111cfe62d5ea845abd4b2c1e77534749864cb6b44cb9dd802126d85ab1025a1a89e8ba26f0ff08591f701f13c7c05aba75de2985ea629c14f6
|
|
7
|
+
data.tar.gz: e7e2f33022fcbc9c670821bff1025709c86cd5900ba52e229150ce4847ff8403fc304888117d942e0c616256af1a6616060dd45d5fe4f0ddf041e963a3a47ed8
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Diffbroker
|
|
4
|
+
# What `diffbroker status` says about folders the scan was refused: the folders and, on macOS, the steps that give the
|
|
5
|
+
# program access (Full Disk Access). Devices says the same in the browser (devices/_scan_roots). Which program needs the
|
|
6
|
+
# grant depends on how the companion runs, so both are named: the Ruby that runs the background service and the terminal.
|
|
7
|
+
module AccessHelp
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def lines(paths, macos: RbConfig::CONFIG["host_os"].to_s.include?("darwin"), ruby: ruby_path)
|
|
11
|
+
return [] if paths.empty?
|
|
12
|
+
|
|
13
|
+
lines = [ "Folders the system would not let the companion read (their repositories are not found):" ]
|
|
14
|
+
lines += paths.map { " #{_1}" }
|
|
15
|
+
lines += macos ? macos_steps(ruby) : [ " Check who may read them (`ls -ld <folder>`): the companion runs as #{ENV["USER"] || "your user"}." ]
|
|
16
|
+
lines
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def macos_steps(ruby)
|
|
20
|
+
[
|
|
21
|
+
"To allow it (macOS):",
|
|
22
|
+
" 1. Open System Settings > Privacy & Security > Full Disk Access.",
|
|
23
|
+
" 2. Click + and add the program that runs the companion: #{ruby}",
|
|
24
|
+
" (in the file picker press Cmd+Shift+G to type a path). A companion you start by hand in a terminal uses that",
|
|
25
|
+
" terminal app's access instead: add the terminal there.",
|
|
26
|
+
" 3. Run `diffbroker restart`.",
|
|
27
|
+
"Or keep your code outside cloud folders (Dropbox, iCloud Drive), which macOS protects."
|
|
28
|
+
]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ruby_path = File.realpath(RbConfig.ruby)
|
|
32
|
+
rescue SystemCallError
|
|
33
|
+
RbConfig.ruby
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -51,12 +51,16 @@ module Diffbroker
|
|
|
51
51
|
event = parse_json(line) or return nil
|
|
52
52
|
|
|
53
53
|
case event["type"]
|
|
54
|
-
when "system"
|
|
54
|
+
when "system"
|
|
55
|
+
return unless event["subtype"] == "init"
|
|
56
|
+
|
|
57
|
+
@billing = billing_for(event["apiKeySource"])
|
|
58
|
+
Progress.new(step: "starting", session_id: event["session_id"])
|
|
55
59
|
when "assistant"
|
|
56
60
|
tool = Array(event.dig("message", "content")).reverse.find { _1.is_a?(Hash) && _1["type"] == "tool_use" }
|
|
57
61
|
tool && Progress.new(step: step_for(tool["name"].to_s, tool["input"].is_a?(Hash) ? tool["input"] : {}), session_id: nil)
|
|
58
62
|
when "result"
|
|
59
|
-
Result.new(cost_usd: event["total_cost_usd"], text: event["result"].to_s, error: event["is_error"] == true)
|
|
63
|
+
Result.new(cost_usd: event["total_cost_usd"], text: event["result"].to_s, error: event["is_error"] == true, billing: @billing)
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
|
|
@@ -74,6 +78,14 @@ module Diffbroker
|
|
|
74
78
|
|
|
75
79
|
private
|
|
76
80
|
|
|
81
|
+
# The init event names where the credentials come from: "none" is the OAuth login of a Claude subscription, anything
|
|
82
|
+
# else (ANTHROPIC_API_KEY, apiKeyHelper, …) is pay-per-token. An event without the key says nothing.
|
|
83
|
+
def billing_for(source)
|
|
84
|
+
return unless source.is_a?(String) && !source.empty?
|
|
85
|
+
|
|
86
|
+
source == "none" ? "subscription" : "api"
|
|
87
|
+
end
|
|
88
|
+
|
|
77
89
|
def log_block(block)
|
|
78
90
|
return unless block.is_a?(Hash)
|
|
79
91
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
3
6
|
module Diffbroker
|
|
4
7
|
module Agents
|
|
5
8
|
# Codex. Autonomous: `codex exec --json` in a workspace-write sandbox with network (spec §4.2 step 5); the MCP
|
|
@@ -9,6 +12,35 @@ module Diffbroker
|
|
|
9
12
|
# TaskRunner never runs Codex headless on an untrusted head.
|
|
10
13
|
class Codex < Base
|
|
11
14
|
READ_ONLY_ARGS = %w[--sandbox read-only].freeze
|
|
15
|
+
STATUS_TIMEOUT = 10
|
|
16
|
+
@billing_cache = {}
|
|
17
|
+
@billing_lock = Mutex.new
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# `codex login status` once per process and command: "Logged in using ChatGPT" is a subscription, "using an API key" is
|
|
21
|
+
# pay-per-token. Anything else, or a failure, is unknown.
|
|
22
|
+
def billing_for(command)
|
|
23
|
+
@billing_lock.synchronize { @billing_cache.fetch(command) { @billing_cache[command] = detect_billing(command) } }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse_login_status(text)
|
|
27
|
+
case text.to_s
|
|
28
|
+
when /logged in using chatgpt/i then "subscription"
|
|
29
|
+
when /logged in using an api key/i then "api"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset_billing_cache! = @billing_lock.synchronize { @billing_cache.clear }
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def detect_billing(command)
|
|
38
|
+
out, = Timeout.timeout(STATUS_TIMEOUT) { Open3.capture2e(command, "login", "status", stdin_data: "") }
|
|
39
|
+
parse_login_status(out)
|
|
40
|
+
rescue StandardError, Timeout::Error
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
12
44
|
|
|
13
45
|
def name = "codex"
|
|
14
46
|
|
|
@@ -25,8 +57,8 @@ module Diffbroker
|
|
|
25
57
|
case event["type"]
|
|
26
58
|
when "thread.started" then Progress.new(step: "starting", session_id: event["thread_id"])
|
|
27
59
|
when "item.started", "item.completed" then item(event["item"].is_a?(Hash) ? event["item"] : {})
|
|
28
|
-
when "turn.completed" then Result.new(cost_usd: nil, text: @last_message.to_s, error: false)
|
|
29
|
-
when "turn.failed", "error" then Result.new(cost_usd: nil, text: @last_message.to_s, error: true)
|
|
60
|
+
when "turn.completed" then Result.new(cost_usd: nil, text: @last_message.to_s, error: false, billing: billing)
|
|
61
|
+
when "turn.failed", "error" then Result.new(cost_usd: nil, text: @last_message.to_s, error: true, billing: billing)
|
|
30
62
|
end
|
|
31
63
|
end
|
|
32
64
|
|
|
@@ -47,6 +79,8 @@ module Diffbroker
|
|
|
47
79
|
|
|
48
80
|
private
|
|
49
81
|
|
|
82
|
+
def billing = self.class.billing_for(@config.codex_command)
|
|
83
|
+
|
|
50
84
|
def item(item)
|
|
51
85
|
case item["type"]
|
|
52
86
|
when "command_execution" then Progress.new(step: "running a command", session_id: nil)
|
data/lib/diffbroker/agents.rb
CHANGED
|
@@ -4,7 +4,10 @@ module Diffbroker
|
|
|
4
4
|
# The user's own coding agents, run headless (autonomous) or interactively (terminal). 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.
|
|
8
|
+
Result = Data.define(:cost_usd, :text, :error, :billing) do
|
|
9
|
+
def initialize(cost_usd:, text:, error:, billing: nil) = super
|
|
10
|
+
end
|
|
8
11
|
# One line of the run for the user's own log (kind: text, thinking, tool, error or result). Tool results (file
|
|
9
12
|
# contents) are never an entry.
|
|
10
13
|
Entry = Data.define(:kind, :text)
|
data/lib/diffbroker/cli.rb
CHANGED
|
@@ -303,9 +303,11 @@ module Diffbroker
|
|
|
303
303
|
@out.puts "Roots: #{config.roots.join(", ")} (plus the projects Claude Code and Codex know)"
|
|
304
304
|
@out.puts "Worktrees: #{config.worktree_root}"
|
|
305
305
|
@out.puts "Task worktrees: #{Worktree.new(root: config.worktree_root).list.size} (running or kept; `diffbroker clean` removes finished ones)"
|
|
306
|
-
|
|
306
|
+
scanner = config.repo_scanner
|
|
307
|
+
repositories = scanner.scan
|
|
307
308
|
@out.puts "Repositories found: #{repositories.size}"
|
|
308
309
|
repositories.each { @out.puts " #{_1["full_name"]} #{_1["path"]}" }
|
|
310
|
+
AccessHelp.lines(scanner.unreadable).each { @out.puts _1 }
|
|
309
311
|
end
|
|
310
312
|
|
|
311
313
|
# What the daemon wrote about itself (DaemonState), next to what this installed version and service would run.
|
data/lib/diffbroker/daemon.rb
CHANGED
|
@@ -242,7 +242,9 @@ module Diffbroker
|
|
|
242
242
|
def rescan(client)
|
|
243
243
|
@repositories = @scanner.scan
|
|
244
244
|
@scanned_at = @clock.call
|
|
245
|
-
|
|
245
|
+
payload = { "repositories" => @repositories }
|
|
246
|
+
payload["unreadable"] = @scanner.unreadable if @scanner.respond_to?(:unreadable) # Devices explains how to grant access
|
|
247
|
+
client.perform("repositories", payload)
|
|
246
248
|
end
|
|
247
249
|
|
|
248
250
|
# The plan limits change slowly and reading them can wait on the network, so: every few minutes, off the tick's thread.
|
|
@@ -49,7 +49,8 @@ module Diffbroker
|
|
|
49
49
|
when "cancelled" then [ "cancelled", "cancelled" ]
|
|
50
50
|
else [ "failed", verify.error_code || "agent_failed" ]
|
|
51
51
|
end
|
|
52
|
-
TaskRunner::Outcome.new(status, costs.empty? ? nil : costs.sum.round(6), code, verify.last_output || write.last_output
|
|
52
|
+
TaskRunner::Outcome.new(status, costs.empty? ? nil : costs.sum.round(6), code, verify.last_output || write.last_output, false,
|
|
53
|
+
[ write.billing, verify.billing ].include?("api") ? "api" : write.billing || verify.billing)
|
|
53
54
|
end
|
|
54
55
|
end
|
|
55
56
|
end
|
|
@@ -21,6 +21,11 @@ module Diffbroker
|
|
|
21
21
|
|
|
22
22
|
# `exclude` (the task worktrees) is skipped everywhere. `ignored` (folders the user removed) is skipped by everything
|
|
23
23
|
# found by itself; `explicit_roots` (folders the user added) are scanned even inside an ignored folder.
|
|
24
|
+
# Folders the last scan was refused (macOS answers EPERM for Dropbox, iCloud, Desktop and the like until the program has
|
|
25
|
+
# been given access; other systems say EACCES). Reported to the server so Devices can explain how to grant it.
|
|
26
|
+
MAX_UNREADABLE = 20
|
|
27
|
+
attr_reader :unreadable
|
|
28
|
+
|
|
24
29
|
def initialize(roots:, depth: 3, exclude: [], hints: [], home: Dir.home, explicit_roots: [], ignored: [])
|
|
25
30
|
@roots = roots
|
|
26
31
|
@explicit_roots = explicit_roots
|
|
@@ -30,10 +35,12 @@ module Diffbroker
|
|
|
30
35
|
@depth = depth
|
|
31
36
|
@exclude = exclude.map { File.expand_path(_1) }
|
|
32
37
|
@skip = @exclude + @ignored
|
|
38
|
+
@unreadable = []
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def scan
|
|
36
42
|
found = {}
|
|
43
|
+
@unreadable = []
|
|
37
44
|
@roots.each { |root| walk(File.expand_path(root), 0, found, @depth, @skip) if File.directory?(root) }
|
|
38
45
|
@explicit_roots.each { |root| walk(File.expand_path(root), 0, found, @depth, @exclude) if File.directory?(root) }
|
|
39
46
|
parents = @hints.filter_map { |hint| add_hint(File.expand_path(hint), found) }.map { File.dirname(_1) }.uniq
|
|
@@ -79,8 +86,10 @@ module Diffbroker
|
|
|
79
86
|
path = File.join(dir, child)
|
|
80
87
|
walk(path, level + 1, found, limit, skip) if File.directory?(path) && !File.symlink?(path)
|
|
81
88
|
end
|
|
89
|
+
rescue Errno::EPERM, Errno::EACCES
|
|
90
|
+
@unreadable << dir if @unreadable.size < MAX_UNREADABLE && !@unreadable.include?(dir)
|
|
82
91
|
rescue SystemCallError
|
|
83
|
-
nil #
|
|
92
|
+
nil # gone meanwhile, or not a folder: nothing to report
|
|
84
93
|
end
|
|
85
94
|
|
|
86
95
|
def remote_names(dir) = GitContext.remote_urls(dir).filter_map { |_name, url| self.class.full_name_from(url) }.uniq
|
|
@@ -12,7 +12,7 @@ module Diffbroker
|
|
|
12
12
|
PRINT_OUTPUT = 120
|
|
13
13
|
|
|
14
14
|
Handle = Struct.new(:pid, :cancelled)
|
|
15
|
-
Outcome = Struct.new(:status, :cost_usd, :error_code, :last_output, :no_result)
|
|
15
|
+
Outcome = Struct.new(:status, :cost_usd, :error_code, :last_output, :no_result, :billing)
|
|
16
16
|
|
|
17
17
|
def initialize(config:, report:, worktree: Worktree.new(root: config.worktree_root), neutralizer: Neutralizer.new,
|
|
18
18
|
gh_check: GhCheck.new, launcher: Launcher.new(name: config.terminal), out: $stdout, grace: CANCEL_GRACE,
|
|
@@ -146,7 +146,7 @@ module Diffbroker
|
|
|
146
146
|
outcome = run_with_resume(task, agent, mcp_config, dir)
|
|
147
147
|
outcome = verify_after_write(task, outcome, agent, mcp_config, dir) if ExplainVerifier.verified?(task) && outcome.status == "done"
|
|
148
148
|
outcome = publish_branch(task, outcome, dir) if outcome.status == "done"
|
|
149
|
-
finished(id, outcome.status, outcome.cost_usd, outcome.error_code, outcome.last_output)
|
|
149
|
+
finished(id, outcome.status, outcome.cost_usd, outcome.error_code, outcome.last_output, outcome.billing)
|
|
150
150
|
ensure
|
|
151
151
|
@lock.synchronize do
|
|
152
152
|
@running.delete(id)
|
|
@@ -184,7 +184,7 @@ module Diffbroker
|
|
|
184
184
|
say(task["task_id"], result.message)
|
|
185
185
|
return outcome unless result.failed?
|
|
186
186
|
|
|
187
|
-
Outcome.new("failed", outcome.cost_usd, "agent_failed", result.message)
|
|
187
|
+
Outcome.new("failed", outcome.cost_usd, "agent_failed", result.message, false, outcome.billing)
|
|
188
188
|
end
|
|
189
189
|
|
|
190
190
|
# The same push for a terminal run, executed by its script after the agent exits (`diffbroker publish-branch`).
|
|
@@ -204,9 +204,9 @@ module Diffbroker
|
|
|
204
204
|
rescue Error => e
|
|
205
205
|
# The explanation is written; keep its cost and let the server show it as not verified.
|
|
206
206
|
say(id, "Verifier skipped: #{e.message}")
|
|
207
|
-
return Outcome.new("done", write.cost_usd, nil, "Verifier skipped: #{e.message}")
|
|
207
|
+
return Outcome.new("done", write.cost_usd, nil, "Verifier skipped: #{e.message}", false, write.billing)
|
|
208
208
|
end
|
|
209
|
-
return Outcome.new("cancelled", write.cost_usd, "cancelled", write.last_output) if @lock.synchronize { @cancelled.include?(id) }
|
|
209
|
+
return Outcome.new("cancelled", write.cost_usd, "cancelled", write.last_output, false, write.billing) if @lock.synchronize { @cancelled.include?(id) }
|
|
210
210
|
|
|
211
211
|
verify = run_process(task.merge("prompt" => prompt), agent, mcp_config, dir, step: "verifying", phase: "verifier")
|
|
212
212
|
ExplainVerifier.combine(write, verify)
|
|
@@ -227,9 +227,9 @@ module Diffbroker
|
|
|
227
227
|
# Only the agent's own result text: a raw stream-json line can carry file contents (spec §5.2).
|
|
228
228
|
output = result&.text
|
|
229
229
|
|
|
230
|
-
if handle.cancelled then Outcome.new("cancelled", result&.cost_usd, "cancelled", output)
|
|
231
|
-
elsif status.success? && !result&.error then Outcome.new("done", result&.cost_usd, nil, output)
|
|
232
|
-
else Outcome.new("failed", result&.cost_usd, "agent_failed", output, result.nil
|
|
230
|
+
if handle.cancelled then Outcome.new("cancelled", result&.cost_usd, "cancelled", output, false, result&.billing)
|
|
231
|
+
elsif status.success? && !result&.error then Outcome.new("done", result&.cost_usd, nil, output, false, result&.billing)
|
|
232
|
+
else Outcome.new("failed", result&.cost_usd, "agent_failed", output, result.nil?, result&.billing)
|
|
233
233
|
end
|
|
234
234
|
ensure
|
|
235
235
|
writer.close if writer && !writer.closed?
|
|
@@ -297,12 +297,14 @@ module Diffbroker
|
|
|
297
297
|
say(id, "progress: #{step}") unless step == "starting"
|
|
298
298
|
end
|
|
299
299
|
|
|
300
|
-
def finished(id, status, cost_usd, error_code, last_output)
|
|
300
|
+
def finished(id, status, cost_usd, error_code, last_output, billing = nil)
|
|
301
301
|
return if @lock.synchronize { @interrupted }
|
|
302
302
|
|
|
303
303
|
last = last_output.to_s.lines.last.to_s.strip
|
|
304
|
-
|
|
305
|
-
|
|
304
|
+
payload = { "task_id" => id, "status" => status, "cost_usd" => cost_usd, "error_code" => error_code,
|
|
305
|
+
"last_output" => last[0, MAX_OUTPUT] }
|
|
306
|
+
payload["billing"] = billing if %w[subscription api].include?(billing)
|
|
307
|
+
@report.call("finished", payload)
|
|
306
308
|
summary = [ (error_code && error_code != status ? "#{status} (#{error_code})" : status), (format("$%.2f", cost_usd) if cost_usd&.positive?),
|
|
307
309
|
(last[0, PRINT_OUTPUT] unless last.empty?) ].compact.join(" · ")
|
|
308
310
|
say(id, summary)
|
data/lib/diffbroker/version.rb
CHANGED
data/lib/diffbroker.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Diffbroker
|
|
|
32
32
|
autoload :Launcher, File.expand_path("diffbroker/launcher", __dir__)
|
|
33
33
|
autoload :Maintenance, File.expand_path("diffbroker/maintenance", __dir__)
|
|
34
34
|
autoload :LocalRelay, File.expand_path("diffbroker/local_relay", __dir__)
|
|
35
|
+
autoload :AccessHelp, File.expand_path("diffbroker/access_help", __dir__)
|
|
35
36
|
autoload :Login, File.expand_path("diffbroker/login", __dir__)
|
|
36
37
|
autoload :Neutralizer, File.expand_path("diffbroker/neutralizer", __dir__)
|
|
37
38
|
autoload :PlanUsage, File.expand_path("diffbroker/plan_usage", __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.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yuri Sidorov (@newstler)
|
|
@@ -34,6 +34,7 @@ files:
|
|
|
34
34
|
- LICENSE.txt
|
|
35
35
|
- exe/diffbroker
|
|
36
36
|
- lib/diffbroker.rb
|
|
37
|
+
- lib/diffbroker/access_help.rb
|
|
37
38
|
- lib/diffbroker/agent_projects.rb
|
|
38
39
|
- lib/diffbroker/agents.rb
|
|
39
40
|
- lib/diffbroker/agents/base.rb
|