diffbroker 0.9.0 → 0.10.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: 3e54b099af52044a49465285a6e1c6433be4fbac0c9e2b76dade0717b42bb5cb
4
- data.tar.gz: c27ba555b4cf84832fac018fabb0a32acbc13a2778a98f526db61304abd8f11f
3
+ metadata.gz: 90e8bd666f8efcb2f12429d70caee992e7e06337041a32333b334beedf0c6053
4
+ data.tar.gz: 48507f0c813906471b8345e3473f8f7f5c729c4c68ebeb46ac45579f3b3ed87e
5
5
  SHA512:
6
- metadata.gz: 71929768b4b9c14a10e969ab4ace0687849082ce27816f4e78a04593d3f177fe3f286ad007e800a7f22bd57f6798b244ba43559ccca864ffde00f89c6e9764f5
7
- data.tar.gz: 437c9ee4dd4b481ede08fc5c080c60bdb366741d33f89da9ae2af4285ae7c8875c9dde3776683612c003dce2eb8eb7b01828a679cbcf8d82f2d1cd07d61d72da
6
+ metadata.gz: 58362a84f6cc68cde8a081c5f02d6177db48656557bb9262390b272357e574343c33153d2118728b8dd18971cb68962199d327d740cc342ca9490e54fbe55196
7
+ data.tar.gz: 6d4ee8a4c7fdc7d8619ed4efc2caef559a354bc28f6987ebe213cb94122ba989ff4686a8ae6a03aec2e72c993318e3aca2a4cc99bb923495082d4493340dab1e
@@ -11,6 +11,7 @@ module Diffbroker
11
11
  class Rejected < Error; end
12
12
 
13
13
  RESCAN_EVERY = 300
14
+ USAGE_EVERY = 300
14
15
  MAX_BACKOFF = 60
15
16
  BATCH = 50
16
17
  # Spec §5.2 / main §18.4: nothing else about an observed agent ever leaves the machine.
@@ -26,6 +27,7 @@ module Diffbroker
26
27
  new(scanner: config.repo_scanner, roots_changed: roots_changed(config), device_roots: config.device_roots, ignored_roots: config.ignored_roots,
27
28
  client_factory: -> { CableClient.new(url: config.cable_url, token: config.device_token, origin: config.server) },
28
29
  task_runner_factory: ->(report) { TaskRunner.new(config:, report:, out:) }, out:, relay:, process_scanner:, agent: config.agent,
30
+ plan_usage: PlanUsage.new(agent: config.agent),
29
31
  lifecycle: Lifecycle.new(dir: config.dir, server: config.server), reaper: Reaper.new(root: config.worktree_root))
30
32
  end
31
33
 
@@ -54,7 +56,7 @@ module Diffbroker
54
56
 
55
57
  def initialize(scanner:, client_factory:, task_runner_factory:, out: $stdout, sleeper: ->(seconds) { sleep(seconds) },
56
58
  clock: -> { Time.now }, spawner: ->(&block) { Thread.new(&block) }, relay: nil, process_scanner: nil, lifecycle: nil,
57
- maintenance: Maintenance.new, roots_changed: nil, device_roots: [], ignored_roots: [], agent: "claude", reaper: nil)
59
+ maintenance: Maintenance.new, roots_changed: nil, device_roots: [], ignored_roots: [], agent: "claude", reaper: nil, plan_usage: nil)
58
60
  @scanner = scanner
59
61
  @device_roots = device_roots
60
62
  @ignored_roots = ignored_roots
@@ -71,6 +73,7 @@ module Diffbroker
71
73
  @maintenance = maintenance
72
74
  @agent = agent
73
75
  @reaper = reaper
76
+ @plan_usage = plan_usage
74
77
  @repositories = []
75
78
  @running = Set.new
76
79
  @running_lock = Mutex.new
@@ -123,6 +126,7 @@ module Diffbroker
123
126
  @backoff = 1
124
127
  @rejections = 0
125
128
  rescan(client)
129
+ @usage_at = nil
126
130
  client.perform("hello", "version" => VERSION, "platform" => Diffbroker.platform,
127
131
  "service" => @lifecycle ? @lifecycle.service_installed? : false, "agent" => @agent)
128
132
  @out.puts "Connected. #{@repositories.size} repositories reported."
@@ -131,6 +135,7 @@ module Diffbroker
131
135
  client.perform("heartbeat", "version" => VERSION)
132
136
  rescan(client) if @scanned_at.nil? || @clock.call - @scanned_at >= RESCAN_EVERY
133
137
  observe(client, @process_scanner.scan) if @process_scanner
138
+ report_usage(client)
134
139
  end
135
140
  client.on_poll { observe(client, @relay.drain) if @relay }
136
141
  client.on_message { |message| handle(client, message) }
@@ -143,6 +148,18 @@ module Diffbroker
143
148
  client.perform("repositories", "repositories" => @repositories)
144
149
  end
145
150
 
151
+ # The plan limits change slowly and reading them can wait on the network, so: every few minutes, off the tick's thread.
152
+ def report_usage(client)
153
+ return unless @plan_usage
154
+ return if @usage_at && @clock.call - @usage_at < USAGE_EVERY
155
+
156
+ @usage_at = @clock.call
157
+ @spawner.call do
158
+ usage = @plan_usage.read
159
+ client.perform("usage", usage) if usage
160
+ end
161
+ end
162
+
146
163
  def observe(client, events)
147
164
  clean = Array(events).filter_map { _1.is_a?(Hash) ? _1.slice(*OBSERVATION_FIELDS) : nil }
148
165
  clean.each_slice(BATCH) { client.perform("observe", "events" => _1) }
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "time"
6
+
7
+ module Diffbroker
8
+ # How much of the user's own plan limits the agent has used: Claude Code's 5-hour and weekly limits, Codex's rate-limit
9
+ # windows. Read on this machine with the user's own login and reported as plain percentages and reset times; no token,
10
+ # prompt or file ever leaves it. Every reader answers nil when it cannot tell (not logged in, no sessions, no network).
11
+ class PlanUsage
12
+ CLAUDE_URL = URI("https://api.anthropic.com/api/oauth/usage")
13
+ CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"
14
+ MAX_WINDOWS = 2
15
+ # A window's key by its length in minutes: the rings and bars name them.
16
+ KEYS = { 300 => "session", 10_080 => "weekly", 43_200 => "monthly" }.freeze
17
+ CODEX_TAIL_BYTES = 400_000
18
+
19
+ def initialize(agent:, home: Dir.home, http: nil, keychain: nil, clock: -> { Time.now })
20
+ @agent = agent
21
+ @home = home
22
+ @http = http || method(:fetch)
23
+ @keychain = keychain || method(:read_keychain)
24
+ @clock = clock
25
+ end
26
+
27
+ # => { "agent" => "claude", "windows" => [{ "key", "percent", "resets_at" }] } or nil
28
+ def read
29
+ windows = @agent == "codex" ? codex_windows : claude_windows
30
+ windows = Array(windows).first(MAX_WINDOWS)
31
+ { "agent" => @agent, "windows" => windows } unless windows.empty?
32
+ rescue StandardError
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ def claude_windows
39
+ token = claude_token
40
+ return unless token
41
+
42
+ body = JSON.parse(@http.call(CLAUDE_URL, token))
43
+ [ [ "five_hour", "session" ], [ "seven_day", "weekly" ] ].filter_map do |field, key|
44
+ window = body[field]
45
+ next unless window.is_a?(Hash) && window["utilization"].is_a?(Numeric)
46
+
47
+ { "key" => key, "percent" => window["utilization"].clamp(0, 100).round(1), "resets_at" => Time.parse(window["resets_at"].to_s).to_i }
48
+ rescue ArgumentError
49
+ nil
50
+ end
51
+ end
52
+
53
+ def claude_token
54
+ raw = @keychain.call || credentials_file
55
+ JSON.parse(raw.to_s).dig("claudeAiOauth", "accessToken")
56
+ rescue JSON::ParserError
57
+ nil
58
+ end
59
+
60
+ def credentials_file
61
+ path = File.join(@home, ".claude", ".credentials.json")
62
+ File.read(path) if File.file?(path)
63
+ end
64
+
65
+ def read_keychain
66
+ return unless RUBY_PLATFORM.include?("darwin")
67
+
68
+ out = IO.popen([ "security", "find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w" ], err: File::NULL, &:read)
69
+ out.to_s.strip.then { _1.empty? ? nil : _1 }
70
+ rescue SystemCallError
71
+ nil
72
+ end
73
+
74
+ def fetch(uri, token)
75
+ request = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{token}", "anthropic-beta" => "oauth-2025-04-20", "Accept" => "application/json")
76
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 8) { _1.request(request) }
77
+ raise Error, "usage #{response.code}" unless response.is_a?(Net::HTTPSuccess)
78
+
79
+ response.body
80
+ end
81
+
82
+ # Codex writes its limits into every session's log; the newest line of the newest session is the current state.
83
+ def codex_windows
84
+ file = Dir.glob(File.join(@home, ".codex", "sessions", "*", "*", "*", "rollout-*.jsonl")).max_by { File.mtime(_1) }
85
+ return unless file
86
+
87
+ line = tail(file).lines.reverse.find { _1.include?('"rate_limits"') }
88
+ limits = line && JSON.parse(line).dig("payload", "rate_limits")
89
+ return unless limits.is_a?(Hash)
90
+
91
+ now = @clock.call.to_i
92
+ windows = %w[primary secondary].filter_map { |name| codex_window(limits[name], now) }
93
+ windows.sort_by { _1["minutes"] }.map { _1.except("minutes") }
94
+ rescue JSON::ParserError
95
+ nil
96
+ end
97
+
98
+ def codex_window(window, now)
99
+ return unless window.is_a?(Hash) && window["used_percent"].is_a?(Numeric) && window["resets_at"].is_a?(Integer)
100
+
101
+ minutes = window["window_minutes"].to_i
102
+ # A window that already reset reports the old percentage until Codex runs again: it is empty now.
103
+ percent = window["resets_at"] <= now ? 0 : window["used_percent"].clamp(0, 100).round(1)
104
+ { "key" => KEYS.fetch(minutes, minutes >= 10_080 ? "weekly" : "session"), "percent" => percent, "resets_at" => window["resets_at"], "minutes" => minutes }
105
+ end
106
+
107
+ def tail(path)
108
+ File.open(path) do |file|
109
+ file.seek([ file.size - CODEX_TAIL_BYTES, 0 ].max)
110
+ file.read.to_s
111
+ end
112
+ end
113
+ end
114
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Diffbroker
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/diffbroker.rb CHANGED
@@ -32,6 +32,7 @@ module Diffbroker
32
32
  autoload :LocalRelay, File.expand_path("diffbroker/local_relay", __dir__)
33
33
  autoload :Login, File.expand_path("diffbroker/login", __dir__)
34
34
  autoload :Neutralizer, File.expand_path("diffbroker/neutralizer", __dir__)
35
+ autoload :PlanUsage, File.expand_path("diffbroker/plan_usage", __dir__)
35
36
  autoload :ProcessScanner, File.expand_path("diffbroker/process_scanner", __dir__)
36
37
  autoload :Reaper, File.expand_path("diffbroker/reaper", __dir__)
37
38
  autoload :RepoScanner, File.expand_path("diffbroker/repo_scanner", __dir__)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "diffbroker",
3
- "version": "0.9.0",
3
+ "version": "0.10.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.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuri Sidorov (@newstler)
@@ -56,6 +56,7 @@ files:
56
56
  - lib/diffbroker/login.rb
57
57
  - lib/diffbroker/maintenance.rb
58
58
  - lib/diffbroker/neutralizer.rb
59
+ - lib/diffbroker/plan_usage.rb
59
60
  - lib/diffbroker/process_scanner.rb
60
61
  - lib/diffbroker/reaper.rb
61
62
  - lib/diffbroker/repo_scanner.rb