diffbroker 0.9.0 → 0.11.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: 5edf0d3084adb3e7b1e8a9f55dda5062efa3a5207d8f4ca7d7860141acef3ec7
4
+ data.tar.gz: 85899028c0a4d827e3df60c530298f9713e4a33e4ccf5ec4397fae0d58a6c37a
5
5
  SHA512:
6
- metadata.gz: 71929768b4b9c14a10e969ab4ace0687849082ce27816f4e78a04593d3f177fe3f286ad007e800a7f22bd57f6798b244ba43559ccca864ffde00f89c6e9764f5
7
- data.tar.gz: 437c9ee4dd4b481ede08fc5c080c60bdb366741d33f89da9ae2af4285ae7c8875c9dde3776683612c003dce2eb8eb7b01828a679cbcf8d82f2d1cd07d61d72da
6
+ metadata.gz: 7b7ca4efd11472c69863a386ebf10336402eac96f998a8665457f20a7f6b395af3a3814925915e1881d0f018c906344a0eccd45cdd0d9557fc4f44fc0a3891be
7
+ data.tar.gz: d763d542d2a6c27adad2bf78cf3b6f0bb1488b0f384bb90fef6990b27c142af472078409f5886be43c92fe6df7b158e847547720018f28ce0efe57ab7060b4dc
@@ -16,6 +16,9 @@ module Diffbroker
16
16
  # The run's lines for the user's log: what the model said and the tools it called. Subclasses override.
17
17
  def parse_log(_line) = []
18
18
 
19
+ # Whether a chat message can continue this agent's earlier session (task["resume_session_id"]).
20
+ def resumes? = false
21
+
19
22
  def env(token, task_id) = { TOKEN_ENV => token, "DIFFBROKER_TASK_ID" => task_id }
20
23
 
21
24
  private
@@ -27,12 +27,17 @@ module Diffbroker
27
27
 
28
28
  def name = "claude_code"
29
29
 
30
+ def resumes? = true
31
+
30
32
  def autonomous_command(task, mcp_config:)
31
33
  args = task["read_only"] ? READ_ONLY_ARGS : @config.claude_args
32
- [ @config.claude_command, "-p", task.fetch("prompt"), "--output-format", "stream-json", "--verbose", *args,
34
+ [ @config.claude_command, "-p", task.fetch("prompt"), *resume(task), "--output-format", "stream-json", "--verbose", *args,
33
35
  "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
34
36
  end
35
37
 
38
+ # The permissions above are passed again: a resumed session keeps its conversation, not its sandbox.
39
+ def resume(task) = task["resume_session_id"] ? [ "--resume", task["resume_session_id"] ] : []
40
+
36
41
  def terminal_command(task, mcp_config:)
37
42
  [ @config.claude_command, "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
38
43
  end
@@ -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
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Diffbroker
7
+ # The agent sessions this companion started itself. The server offers to resume a chat's earlier session by id, but it is
8
+ # only ever resumed here when this machine made it: a server (or anyone who could fake its message) cannot make an agent
9
+ # continue another conversation of the user's, such as an interactive session in their own terminal.
10
+ class SessionLedger
11
+ FILE = "sessions.json"
12
+ LIMIT = 500
13
+ ID = /\A\w[\w-]{0,99}\z/
14
+
15
+ def initialize(state_dir:)
16
+ @path = File.join(state_dir, FILE)
17
+ @lock = Mutex.new
18
+ end
19
+
20
+ def remember(id)
21
+ return unless id.to_s.match?(ID)
22
+
23
+ @lock.synchronize do
24
+ ids = read
25
+ next if ids.include?(id)
26
+
27
+ write((ids + [ id ]).last(LIMIT))
28
+ end
29
+ end
30
+
31
+ def known?(id) = id.to_s.match?(ID) && @lock.synchronize { read.include?(id) }
32
+
33
+ private
34
+
35
+ def read
36
+ data = JSON.parse(File.read(@path))
37
+ data.is_a?(Array) ? data.grep(String) : []
38
+ rescue Errno::ENOENT, JSON::ParserError
39
+ []
40
+ end
41
+
42
+ def write(ids)
43
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
44
+ File.write(@path, JSON.generate(ids), perm: 0o600)
45
+ end
46
+ end
47
+ end
@@ -12,12 +12,12 @@ 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)
15
+ Outcome = Struct.new(:status, :cost_usd, :error_code, :last_output, :no_result)
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,
19
19
  progress_interval: PROGRESS_INTERVAL, run_script: RunScript.new(state_dir: config.state_dir),
20
- reaper: Reaper.new(root: config.worktree_root))
20
+ 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
@@ -29,6 +29,7 @@ module Diffbroker
29
29
  @progress_interval = progress_interval
30
30
  @run_script = run_script
31
31
  @reaper = reaper
32
+ @sessions = sessions
32
33
  @handles = {}
33
34
  @running = Set.new
34
35
  @cancelled = Set.new
@@ -132,7 +133,7 @@ module Diffbroker
132
133
  def run_autonomous(task, agent, mcp_config, dir)
133
134
  id = task["task_id"]
134
135
  @lock.synchronize { @running << id }
135
- outcome = run_process(task, agent, mcp_config, dir)
136
+ outcome = run_with_resume(task, agent, mcp_config, dir)
136
137
  outcome = verify_after_write(task, outcome, agent, mcp_config, dir) if ExplainVerifier.verified?(task) && outcome.status == "done"
137
138
  outcome = publish_branch(task, outcome, dir) if outcome.status == "done"
138
139
  finished(id, outcome.status, outcome.cost_usd, outcome.error_code, outcome.last_output)
@@ -143,6 +144,27 @@ module Diffbroker
143
144
  end
144
145
  end
145
146
 
147
+ # A chat message the server offered to resume (task["resume"]) continues the agent's earlier session, when this machine made it
148
+ # and this agent can: it gets only the new question and keeps what it read. A session that cannot be resumed (deleted, expired)
149
+ # makes the agent exit without a word: the message then runs again from scratch with the full prompt, which carries the chat.
150
+ def run_with_resume(task, agent, mcp_config, dir)
151
+ resume = resume_for(task, agent)
152
+ return run_process(task, agent, mcp_config, dir) unless resume
153
+
154
+ outcome = run_process(task.merge("prompt" => resume["prompt"], "resume_session_id" => resume["session_id"]), agent, mcp_config, dir)
155
+ return outcome unless outcome.status == "failed" && outcome.no_result
156
+
157
+ say(task["task_id"], "Could not resume the earlier session: starting this message from scratch")
158
+ run_process(task, agent, mcp_config, dir)
159
+ end
160
+
161
+ def resume_for(task, agent)
162
+ resume = task["resume"]
163
+ return unless resume.is_a?(Hash) && agent.resumes? && resume["agent"] == agent.name && resume["prompt"].to_s != ""
164
+
165
+ resume if @sessions.known?(resume["session_id"])
166
+ end
167
+
146
168
  # An update_branch task ends with a rebase the agent may not push (see BranchPublisher): the companion does, here.
147
169
  def publish_branch(task, outcome, dir)
148
170
  return outcome unless publishes_branch?(task)
@@ -197,7 +219,7 @@ module Diffbroker
197
219
 
198
220
  if handle.cancelled then Outcome.new("cancelled", result&.cost_usd, "cancelled", output)
199
221
  elsif status.success? && !result&.error then Outcome.new("done", result&.cost_usd, nil, output)
200
- else Outcome.new("failed", result&.cost_usd, "agent_failed", output)
222
+ else Outcome.new("failed", result&.cost_usd, "agent_failed", output, result.nil?)
201
223
  end
202
224
  ensure
203
225
  writer.close if writer && !writer.closed?
@@ -221,6 +243,10 @@ module Diffbroker
221
243
  case (event = agent.parse_stream(line))
222
244
  when Agents::Result then result = event
223
245
  when Agents::Progress
246
+ if event.session_id
247
+ @sessions.remember(event.session_id)
248
+ progress(id, event.step, nil, nil, event.session_id)
249
+ end
224
250
  next if event.step == last_step || monotonic - last_sent < @progress_interval
225
251
 
226
252
  progress(id, event.step)
@@ -256,8 +282,8 @@ module Diffbroker
256
282
  end
257
283
  end
258
284
 
259
- def progress(id, step, agent = nil, phase = nil)
260
- @report.call("progress", { "task_id" => id, "step" => step, "agent" => agent, "phase" => phase }.compact)
285
+ def progress(id, step, agent = nil, phase = nil, session_id = nil)
286
+ @report.call("progress", { "task_id" => id, "step" => step, "agent" => agent, "phase" => phase, "session_id" => session_id }.compact)
261
287
  say(id, "progress: #{step}") unless step == "starting"
262
288
  end
263
289
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Diffbroker
4
- VERSION = "0.9.0"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/diffbroker.rb CHANGED
@@ -32,12 +32,14 @@ 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__)
38
39
  autoload :RunScript, File.expand_path("diffbroker/run_script", __dir__)
39
40
  autoload :Runner, File.expand_path("diffbroker/runner", __dir__)
40
41
  autoload :ServiceInstaller, File.expand_path("diffbroker/service_installer", __dir__)
42
+ autoload :SessionLedger, File.expand_path("diffbroker/session_ledger", __dir__)
41
43
  autoload :SkillInstaller, File.expand_path("diffbroker/skill_installer", __dir__)
42
44
  autoload :TaskFiles, File.expand_path("diffbroker/task_files", __dir__)
43
45
  autoload :TaskRunner, File.expand_path("diffbroker/task_runner", __dir__)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "diffbroker",
3
- "version": "0.9.0",
3
+ "version": "0.11.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.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuri Sidorov (@newstler)
@@ -56,12 +56,14 @@ 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
62
63
  - lib/diffbroker/run_script.rb
63
64
  - lib/diffbroker/runner.rb
64
65
  - lib/diffbroker/service_installer.rb
66
+ - lib/diffbroker/session_ledger.rb
65
67
  - lib/diffbroker/skill_installer.rb
66
68
  - lib/diffbroker/task_files.rb
67
69
  - lib/diffbroker/task_runner.rb