diffbroker 0.14.0 → 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: 432bb946c0772a58cf38c9234bc6f717c79f4aa727aad1bebb7b07f17c9c5909
4
- data.tar.gz: 812611ec01f56bb33748565704829e4cc0b46267319d893a161ff88e0b5ee189
3
+ metadata.gz: d1d00b5898842a32e456087716d26bdb5b1c28e35f04e44b9d73acf8726d78f7
4
+ data.tar.gz: 25464398637a5bcf58c4e85f8babb57134cd1d3463e0325bb445b983328c7e09
5
5
  SHA512:
6
- metadata.gz: c748db09f1bba8074c246fa20634dddd77ad96603441f82402ca9b42302cbc5df5b21913bea1b2900af1b2130bf7bc2a3b4cd1d85ae325ea5ce828705136d728
7
- data.tar.gz: fa95a2a4958005c0d49858e20bc71b840686ec02b12eeb7117f95710a83a049e8333dd3939667e4698a0f5589886f7d808aeee2099cabf286958a41f1177baf9
6
+ metadata.gz: 306e519a17b426111cfe62d5ea845abd4b2c1e77534749864cb6b44cb9dd802126d85ab1025a1a89e8ba26f0ff08591f701f13c7c05aba75de2985ea629c14f6
7
+ data.tar.gz: e7e2f33022fcbc9c670821bff1025709c86cd5900ba52e229150ce4847ff8403fc304888117d942e0c616256af1a6616060dd45d5fe4f0ddf041e963a3a47ed8
@@ -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)
@@ -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
@@ -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.14.0"
4
+ VERSION = "0.15.0"
5
5
  end
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "diffbroker",
3
- "version": "0.14.0",
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.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuri Sidorov (@newstler)