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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fc1c3a662ccc736b0c62b0d083dbac979f52cf13f1c15e7eb5e12c009bdefe5
4
- data.tar.gz: a2b6d513455ba9439a99b67603b9507b83aa3e510c323b2c94cf8748b5157e96
3
+ metadata.gz: d1d00b5898842a32e456087716d26bdb5b1c28e35f04e44b9d73acf8726d78f7
4
+ data.tar.gz: 25464398637a5bcf58c4e85f8babb57134cd1d3463e0325bb445b983328c7e09
5
5
  SHA512:
6
- metadata.gz: 4dde5f0960aa3d14efcdc9e8c301a997bce559dff9ff14e836c4af2cbb5d5dcd4b8c35892b88a354aa0d30c593158c217f0e5559cf18456bd5531b1a3523bd53
7
- data.tar.gz: acd78b508a85c294dab674659e7ed72caa263f712e688ea6006169ea92293106f19136547b7aac70b1be437c60eff80357c8f89e182689e18888518821a60807
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" then Progress.new(step: "starting", session_id: event["session_id"]) if event["subtype"] == "init"
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)
@@ -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
- Result = Data.define(:cost_usd, :text, :error)
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)
@@ -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
- repositories = config.repo_scanner.scan
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.
@@ -242,7 +242,9 @@ module Diffbroker
242
242
  def rescan(client)
243
243
  @repositories = @scanner.scan
244
244
  @scanned_at = @clock.call
245
- client.perform("repositories", "repositories" => @repositories)
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 # a folder that cannot be listed (macOS answers EPERM for Dropbox, iCloud and other protected folders) holds nothing for us
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
- @report.call("finished", { "task_id" => id, "status" => status, "cost_usd" => cost_usd, "error_code" => error_code,
305
- "last_output" => last[0, MAX_OUTPUT] })
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Diffbroker
4
- VERSION = "0.13.1"
4
+ VERSION = "0.15.0"
5
5
  end
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__)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "diffbroker",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Skills for working on Diff Broker agent tasks: the diffbroker MCP tools, write rules and report_task."
5
5
  }
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.13.1
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