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 +4 -4
- data/lib/diffbroker/daemon.rb +18 -1
- data/lib/diffbroker/plan_usage.rb +114 -0
- 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: 90e8bd666f8efcb2f12429d70caee992e7e06337041a32333b334beedf0c6053
|
|
4
|
+
data.tar.gz: 48507f0c813906471b8345e3473f8f7f5c729c4c68ebeb46ac45579f3b3ed87e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58362a84f6cc68cde8a081c5f02d6177db48656557bb9262390b272357e574343c33153d2118728b8dd18971cb68962199d327d740cc342ca9490e54fbe55196
|
|
7
|
+
data.tar.gz: 6d4ee8a4c7fdc7d8619ed4efc2caef559a354bc28f6987ebe213cb94122ba989ff4686a8ae6a03aec2e72c993318e3aca2a4cc99bb923495082d4493340dab1e
|
data/lib/diffbroker/daemon.rb
CHANGED
|
@@ -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
|
data/lib/diffbroker/version.rb
CHANGED
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__)
|
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.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
|