openclacky 1.5.6 → 1.5.7

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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +18 -1
  3. data/.clackyrules +25 -1
  4. data/CHANGELOG.md +26 -0
  5. data/CONTRIBUTING.md +9 -0
  6. data/Rakefile +15 -0
  7. data/docs/design-emit-and-background-skills.md +253 -0
  8. data/docs/mcp-architecture.md +48 -9
  9. data/docs/mcp.example.json +9 -0
  10. data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
  11. data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
  12. data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
  13. data/lib/clacky/agent/cost_tracker.rb +17 -2
  14. data/lib/clacky/agent/hook_manager.rb +14 -3
  15. data/lib/clacky/agent/memory_updater.rb +17 -18
  16. data/lib/clacky/agent/message_compressor_helper.rb +23 -0
  17. data/lib/clacky/agent/session_serializer.rb +136 -22
  18. data/lib/clacky/agent/skill_auto_creator.rb +1 -2
  19. data/lib/clacky/agent/skill_evolution.rb +11 -7
  20. data/lib/clacky/agent/skill_manager.rb +20 -18
  21. data/lib/clacky/agent/skill_reflector.rb +3 -6
  22. data/lib/clacky/agent.rb +111 -20
  23. data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
  24. data/lib/clacky/cli.rb +4 -0
  25. data/lib/clacky/client.rb +1 -1
  26. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
  27. data/lib/clacky/extension/api_extension.rb +11 -3
  28. data/lib/clacky/fanout.rb +100 -0
  29. data/lib/clacky/locales/en.rb +2 -0
  30. data/lib/clacky/locales/zh.rb +2 -0
  31. data/lib/clacky/mcp/cli_commands.rb +61 -0
  32. data/lib/clacky/mcp/client.rb +18 -2
  33. data/lib/clacky/mcp/connection_manager.rb +85 -0
  34. data/lib/clacky/mcp/http_transport.rb +25 -9
  35. data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
  36. data/lib/clacky/mcp/oauth/config.rb +51 -0
  37. data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
  38. data/lib/clacky/mcp/oauth/session.rb +88 -0
  39. data/lib/clacky/message_format/anthropic.rb +27 -13
  40. data/lib/clacky/message_history.rb +32 -1
  41. data/lib/clacky/providers.rb +11 -5
  42. data/lib/clacky/server/http_server.rb +42 -22
  43. data/lib/clacky/server/session_registry.rb +11 -15
  44. data/lib/clacky/server/web_ui_controller.rb +1 -1
  45. data/lib/clacky/session_manager.rb +13 -11
  46. data/lib/clacky/shell_hook_loader.rb +14 -8
  47. data/lib/clacky/tools/invoke_skill.rb +3 -2
  48. data/lib/clacky/tools/terminal.rb +11 -4
  49. data/lib/clacky/ui2/progress_handle.rb +27 -3
  50. data/lib/clacky/ui2/ui_controller.rb +206 -6
  51. data/lib/clacky/ui_interface.rb +13 -3
  52. data/lib/clacky/utils/arguments_parser.rb +73 -4
  53. data/lib/clacky/utils/model_pricing.rb +21 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +10 -3
  56. data/lib/clacky/web/features/new-session/view.js +19 -2
  57. data/lib/clacky/web/i18n.js +22 -8
  58. data/lib/clacky/web/index.html +7 -11
  59. data/lib/clacky/web/projects.js +26 -0
  60. data/lib/clacky/web/sessions.js +169 -119
  61. data/lib/clacky/web/settings.js +1 -0
  62. data/lib/clacky/web/ws-dispatcher.js +61 -34
  63. data/lib/clacky.rb +2 -0
  64. metadata +12 -1
@@ -23,12 +23,15 @@ module Clacky
23
23
  DEFAULT_OPEN_TIMEOUT = 10
24
24
  DEFAULT_READ_TIMEOUT = 120
25
25
 
26
- def initialize(name:, url:, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
26
+ def initialize(name:, url:, headers: {}, authorization: nil, requester: nil,
27
+ open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
27
28
  @name = name
28
29
  @uri = URI.parse(url)
29
30
  raise TransportError, "MCP server '#{name}' url is not http(s): #{url}" unless %w[http https].include?(@uri.scheme)
30
31
 
31
32
  @extra_headers = (headers || {}).transform_keys(&:to_s).transform_values(&:to_s)
33
+ @authorization = authorization
34
+ @requester = requester
32
35
  @open_timeout = open_timeout
33
36
  @read_timeout = read_timeout
34
37
 
@@ -79,21 +82,17 @@ module Clacky
79
82
  @last_error ? "last error: #{@last_error.class}: #{@last_error.message}" : ""
80
83
  end
81
84
 
82
- private def dispatch_post(body, is_request:)
83
- http = Net::HTTP.new(@uri.host, @uri.port)
84
- http.use_ssl = (@uri.scheme == "https")
85
- http.open_timeout = @open_timeout
86
- http.read_timeout = @read_timeout
87
-
85
+ private def dispatch_post(body, is_request:, retried: false)
88
86
  req = Net::HTTP::Post.new(@uri.request_uri)
89
87
  req["Content-Type"] = "application/json"
90
88
  req["Accept"] = "application/json, text/event-stream"
91
89
  req["MCP-Protocol-Version"] = Client::PROTOCOL_VERSION if defined?(Client::PROTOCOL_VERSION)
92
90
  @lock.synchronize { req["Mcp-Session-Id"] = @session_id if @session_id }
93
91
  @extra_headers.each { |k, v| req[k] = v }
92
+ @authorization&.authorization_headers&.each { |k, v| req[k] = v }
94
93
  req.body = body
95
94
 
96
- http.request(req) do |res|
95
+ perform_request(req) do |res|
97
96
  if (sid = res["Mcp-Session-Id"])
98
97
  @lock.synchronize { @session_id = sid }
99
98
  end
@@ -102,9 +101,16 @@ module Clacky
102
101
  if status == 202
103
102
  return
104
103
  end
104
+ if status == 401 && @authorization && !retried
105
+ res.read_body.to_s
106
+ @authorization.invalidate!
107
+ return dispatch_post(body, is_request: is_request, retried: true)
108
+ end
105
109
  if status >= 400
106
110
  text = res.read_body.to_s
107
- raise TransportError, "HTTP #{status} from MCP server '#{@name}': #{text[0, 500]}"
111
+ message = "HTTP #{status} from MCP server '#{@name}'"
112
+ message += ": #{text[0, 500]}" unless status == 401
113
+ raise TransportError, message
108
114
  end
109
115
 
110
116
  ctype = (res["Content-Type"] || "").downcase
@@ -123,6 +129,16 @@ module Clacky
123
129
  end
124
130
  end
125
131
 
132
+ private def perform_request(request, &block)
133
+ return @requester.call(request, &block) if @requester
134
+
135
+ http = Net::HTTP.new(@uri.host, @uri.port)
136
+ http.use_ssl = (@uri.scheme == "https")
137
+ http.open_timeout = @open_timeout
138
+ http.read_timeout = @read_timeout
139
+ http.request(request, &block)
140
+ end
141
+
126
142
  private def consume_sse(res)
127
143
  buffer = String.new
128
144
  res.read_body do |chunk|
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "net/http"
6
+ require "securerandom"
7
+ require "digest"
8
+ require "base64"
9
+ require "socket"
10
+ require "timeout"
11
+
12
+ module Clacky
13
+ module Mcp
14
+ module OAuth
15
+ class AuthorizationManager
16
+ Response = Struct.new(:status, :headers, :body)
17
+ MAX_RESPONSE_BYTES = 1_048_576
18
+
19
+ class Error < StandardError; end
20
+
21
+ def self.pkce_challenge(verifier)
22
+ Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
23
+ end
24
+
25
+ def initialize(server_name:, config:, store:, requester: nil, callback: nil, clock: nil)
26
+ @server_name = server_name
27
+ @config = config
28
+ @store = store
29
+ @requester = requester || method(:request)
30
+ @callback = callback
31
+ @clock = clock || -> { Time.now.to_i }
32
+ end
33
+
34
+ def login(resource_metadata_url: nil)
35
+ raise Error, "MCP server '#{@server_name}' is not configured for OAuth" unless @config.enabled?
36
+
37
+ receiver = @callback || CallbackReceiver.new
38
+ redirect_uri = receiver.respond_to?(:redirect_uri) ? receiver.redirect_uri : "http://127.0.0.1:52963/callback"
39
+ metadata = discover_metadata(resource_metadata_url)
40
+ client = register_client(metadata, redirect_uri)
41
+ verifier = SecureRandom.urlsafe_base64(48, false)
42
+ state = SecureRandom.urlsafe_base64(32, false)
43
+ authorization_url = build_authorization_url(metadata, client.fetch("client_id"), redirect_uri, verifier, state)
44
+ callback_result = receiver.call(authorization_url, state)
45
+ raise Error, "OAuth callback state mismatch" unless callback_result["state"].to_s == state
46
+ raise Error, "OAuth authorization returned no code" if callback_result["code"].to_s.empty?
47
+
48
+ token = token_request(metadata.fetch("token_endpoint"), {
49
+ "grant_type" => "authorization_code",
50
+ "code" => callback_result.fetch("code"),
51
+ "redirect_uri" => redirect_uri,
52
+ "client_id" => client.fetch("client_id"),
53
+ "code_verifier" => verifier
54
+ })
55
+ grant = normalize_token(token).merge(
56
+ "client_id" => client.fetch("client_id"),
57
+ "client_secret" => client["client_secret"],
58
+ "token_endpoint_auth_method" => client["token_endpoint_auth_method"] || "none",
59
+ "token_endpoint" => metadata.fetch("token_endpoint"),
60
+ "authorization_server" => metadata.fetch("issuer"),
61
+ "resource" => @config.resource
62
+ )
63
+ @store.save(grant)
64
+ ensure
65
+ receiver.close if defined?(receiver) && receiver.respond_to?(:close)
66
+ end
67
+
68
+ def refresh(grant)
69
+ refresh_token = grant["refresh_token"].to_s
70
+ raise Error, "OAuth refresh token is missing; log in again" if refresh_token.empty?
71
+
72
+ fields = {
73
+ "grant_type" => "refresh_token",
74
+ "refresh_token" => refresh_token,
75
+ "client_id" => grant.fetch("client_id")
76
+ }
77
+ token = token_request(grant.fetch("token_endpoint"), fields,
78
+ client_secret: grant["client_secret"],
79
+ auth_method: grant["token_endpoint_auth_method"])
80
+ updated = grant.merge(normalize_token(token))
81
+ updated["refresh_token"] = refresh_token if token["refresh_token"].to_s.empty?
82
+ updated
83
+ rescue KeyError
84
+ raise Error, "stored OAuth grant is incomplete; log in again"
85
+ end
86
+
87
+ private def discover_metadata(explicit_resource_metadata_url)
88
+ protected_url = explicit_resource_metadata_url || default_resource_metadata_url
89
+ protected = get_json(protected_url, "protected resource metadata")
90
+ if protected["resource"] && protected["resource"] != @config.resource
91
+ raise Error, "OAuth protected resource metadata does not match the configured MCP resource"
92
+ end
93
+
94
+ server = Array(protected["authorization_servers"]).first || protected["authorization_server"]
95
+ server = validate_https_url(server, "authorization server")
96
+ metadata_url = "#{server.sub(%r{/\z}, '')}/.well-known/oauth-authorization-server"
97
+ metadata = get_json(metadata_url, "authorization server metadata")
98
+ metadata["issuer"] ||= server
99
+ %w[issuer authorization_endpoint token_endpoint registration_endpoint].each do |key|
100
+ metadata[key] = validate_https_url(metadata[key], key.tr("_", " "))
101
+ end
102
+ raise Error, "authorization server metadata issuer does not match" unless metadata["issuer"] == server
103
+ unless Array(metadata["code_challenge_methods_supported"]).include?("S256")
104
+ raise Error, "authorization server does not support PKCE S256"
105
+ end
106
+ metadata
107
+ end
108
+
109
+ private def default_resource_metadata_url
110
+ resource = URI.parse(@config.resource)
111
+ origin = "#{resource.scheme}://#{resource.host}"
112
+ origin += ":#{resource.port}" unless resource.port == 443
113
+ "#{origin}/.well-known/oauth-protected-resource#{resource.path}"
114
+ end
115
+
116
+ private def register_client(metadata, redirect_uri)
117
+ response = @requester.call("POST", metadata.fetch("registration_endpoint"),
118
+ { "Content-Type" => "application/json" },
119
+ JSON.generate(
120
+ "client_name" => "openclacky",
121
+ "application_type" => "native",
122
+ "redirect_uris" => [redirect_uri],
123
+ "grant_types" => %w[authorization_code refresh_token],
124
+ "response_types" => ["code"],
125
+ "token_endpoint_auth_method" => "none",
126
+ "scope" => "openid profile email offline_access"
127
+ ))
128
+ client = parse_success_json(response, "OAuth client registration")
129
+ raise Error, "OAuth client registration returned no client_id" if client["client_id"].to_s.empty?
130
+ client
131
+ end
132
+
133
+ private def build_authorization_url(metadata, client_id, redirect_uri, verifier, state)
134
+ uri = URI.parse(metadata.fetch("authorization_endpoint"))
135
+ uri.query = URI.encode_www_form(
136
+ "response_type" => "code",
137
+ "client_id" => client_id,
138
+ "redirect_uri" => redirect_uri,
139
+ "scope" => "openid profile email offline_access",
140
+ "code_challenge" => self.class.pkce_challenge(verifier),
141
+ "code_challenge_method" => "S256",
142
+ "state" => state,
143
+ "resource" => @config.resource
144
+ )
145
+ uri.to_s
146
+ end
147
+
148
+ private def token_request(url, fields, client_secret: nil, auth_method: nil)
149
+ headers = { "Content-Type" => "application/x-www-form-urlencoded" }
150
+ if client_secret && auth_method == "client_secret_basic"
151
+ credentials = Base64.strict_encode64("#{fields['client_id']}:#{client_secret}")
152
+ headers["Authorization"] = "Basic #{credentials}"
153
+ elsif client_secret
154
+ fields = fields.merge("client_secret" => client_secret)
155
+ end
156
+ response = @requester.call("POST", validate_https_url(url, "token endpoint"), headers,
157
+ URI.encode_www_form(fields))
158
+ parse_success_json(response, "OAuth token exchange")
159
+ end
160
+
161
+ private def normalize_token(token)
162
+ access_token = token["access_token"].to_s
163
+ raise Error, "OAuth token response contained no access token" if access_token.empty?
164
+ {
165
+ "access_token" => access_token,
166
+ "refresh_token" => token["refresh_token"],
167
+ "token_type" => token["token_type"] || "Bearer",
168
+ "expires_at" => @clock.call + token.fetch("expires_in", 3600).to_i
169
+ }
170
+ end
171
+
172
+ private def get_json(url, label)
173
+ response = @requester.call("GET", validate_https_url(url, label), { "Accept" => "application/json" }, nil)
174
+ parse_success_json(response, label)
175
+ end
176
+
177
+ private def parse_success_json(response, label)
178
+ status = response.status.to_i
179
+ raise Error, "#{label} failed with HTTP #{status}" unless status >= 200 && status < 300
180
+ body = response.body.to_s
181
+ raise Error, "#{label} response was too large" if body.bytesize > MAX_RESPONSE_BYTES
182
+ value = JSON.parse(body)
183
+ raise JSON::ParserError unless value.is_a?(Hash)
184
+ value
185
+ rescue JSON::ParserError
186
+ raise Error, "#{label} returned invalid JSON"
187
+ end
188
+
189
+ private def validate_https_url(value, label)
190
+ uri = URI.parse(value.to_s)
191
+ unless uri.scheme == "https" && uri.host && !uri.host.empty? && !uri.user && !uri.password
192
+ raise Error, "#{label} must be an HTTPS URL without credentials"
193
+ end
194
+ uri.to_s
195
+ rescue URI::InvalidURIError
196
+ raise Error, "#{label} must be an HTTPS URL"
197
+ end
198
+
199
+ private def request(method, url, headers, body)
200
+ uri = URI.parse(url)
201
+ http = Net::HTTP.new(uri.host, uri.port)
202
+ http.use_ssl = true
203
+ http.open_timeout = 10
204
+ http.read_timeout = 30
205
+ request_class = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
206
+ request = request_class.new(uri.request_uri)
207
+ headers.each { |key, value| request[key] = value }
208
+ request.body = body if body
209
+ response = http.request(request)
210
+ Response.new(response.code.to_i, response.each_header.to_h, response.body.to_s)
211
+ rescue StandardError => e
212
+ raise Error, "OAuth network request failed (#{e.class})"
213
+ end
214
+
215
+ class CallbackReceiver
216
+ attr_reader :redirect_uri
217
+
218
+ def initialize(timeout: 300)
219
+ @server = TCPServer.new("127.0.0.1", 0)
220
+ @timeout = timeout
221
+ @redirect_uri = "http://127.0.0.1:#{@server.addr[1]}/callback"
222
+ end
223
+
224
+ def call(authorization_url, _expected_state)
225
+ open_browser(authorization_url)
226
+ socket = Timeout.timeout(@timeout) { @server.accept }
227
+ request_line = socket.gets.to_s
228
+ path = request_line.split(" ")[1].to_s
229
+ query = URI.decode_www_form(URI.parse(path).query.to_s).to_h
230
+ socket.write("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\nOpenClacky authorization complete. You may close this tab.\n")
231
+ query
232
+ rescue Timeout::Error
233
+ raise Error, "OAuth authorization timed out"
234
+ ensure
235
+ socket.close if defined?(socket) && socket
236
+ end
237
+
238
+ def close
239
+ @server.close unless @server.closed?
240
+ end
241
+
242
+ private def open_browser(url)
243
+ command = if RUBY_PLATFORM.include?("darwin")
244
+ ["open", url]
245
+ elsif RUBY_PLATFORM =~ /mswin|mingw|cygwin/
246
+ ["cmd", "/c", "start", "", url]
247
+ else
248
+ ["xdg-open", url]
249
+ end
250
+ opened = system(*command, out: File::NULL, err: File::NULL)
251
+ raise Error, "could not open a browser for OAuth authorization" unless opened
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Clacky
6
+ module Mcp
7
+ module OAuth
8
+ class Config
9
+ class Error < StandardError; end
10
+
11
+ attr_reader :resource
12
+
13
+ def self.from_server_spec(spec)
14
+ spec ||= {}
15
+ auth = spec["auth"]
16
+ enabled = auth.is_a?(Hash) && !auth.empty?
17
+ if enabled && auth["type"].to_s != "oauth"
18
+ raise Error, "unsupported MCP authentication type '#{auth['type']}'"
19
+ end
20
+
21
+ resource = enabled ? (auth["resource"] || spec["url"]) : spec["url"]
22
+ new(enabled: enabled, resource: resource, endpoint: spec["url"])
23
+ end
24
+
25
+ def initialize(enabled:, resource:, endpoint: nil)
26
+ @enabled = enabled
27
+ @resource = resource.to_s
28
+ @endpoint = (endpoint || resource).to_s
29
+ validate! if enabled?
30
+ end
31
+
32
+ def enabled?
33
+ @enabled == true
34
+ end
35
+
36
+ private def validate!
37
+ validate_https_url(@endpoint, "OAuth MCP endpoint")
38
+ validate_https_url(@resource, "OAuth MCP resource")
39
+ end
40
+
41
+ private def validate_https_url(value, label)
42
+ uri = URI.parse(value)
43
+ raise Error, "#{label} must be an HTTPS URL without credentials" unless
44
+ uri.scheme == "https" && uri.host && !uri.host.empty? && !uri.user && !uri.password
45
+ rescue URI::InvalidURIError
46
+ raise Error, "#{label} must be an HTTPS URL"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "digest"
6
+
7
+ module Clacky
8
+ module Mcp
9
+ module OAuth
10
+ class CredentialStore
11
+ class Error < StandardError; end
12
+
13
+ attr_reader :path
14
+
15
+ def initialize(server_name:, home: Dir.home)
16
+ original_name = server_name.to_s
17
+ safe_name = original_name.gsub(/[^a-zA-Z0-9_.-]/, "_")
18
+ safe_name = "server" if safe_name.empty?
19
+ safe_name = "#{safe_name}-#{Digest::SHA256.hexdigest(original_name)[0, 12]}" if safe_name != original_name
20
+ @path = File.join(File.expand_path(home), ".clacky", "mcp", "oauth", "#{safe_name}.json")
21
+ end
22
+
23
+ def load
24
+ return nil unless File.file?(@path)
25
+
26
+ with_lock(File::LOCK_SH) do
27
+ value = JSON.parse(File.read(@path))
28
+ raise JSON::ParserError unless value.is_a?(Hash)
29
+ value
30
+ end
31
+ rescue JSON::ParserError
32
+ raise Error, "invalid OAuth credential file; log in again"
33
+ end
34
+
35
+ def save(value)
36
+ ensure_directory
37
+ with_lock(File::LOCK_EX) do
38
+ temporary = "#{@path}.tmp-#{Process.pid}-#{rand(1_000_000)}"
39
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
40
+ file.write(JSON.generate(value))
41
+ file.write("\n")
42
+ file.flush
43
+ file.fsync
44
+ end
45
+ File.rename(temporary, @path)
46
+ File.chmod(0o600, @path)
47
+ ensure
48
+ FileUtils.rm_f(temporary) if defined?(temporary) && temporary
49
+ end
50
+ value
51
+ end
52
+
53
+ def delete
54
+ return unless File.exist?(@path)
55
+
56
+ ensure_directory
57
+ with_lock(File::LOCK_EX) { FileUtils.rm_f(@path) }
58
+ end
59
+
60
+ def inspect
61
+ "#<#{self.class} path=#{@path.inspect}>"
62
+ end
63
+
64
+ private def ensure_directory
65
+ FileUtils.mkdir_p(File.dirname(@path), mode: 0o700)
66
+ File.chmod(0o700, File.dirname(@path))
67
+ end
68
+
69
+ private def with_lock(mode)
70
+ ensure_directory
71
+ lock_path = "#{@path}.lock"
72
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
73
+ lock.flock(mode)
74
+ yield
75
+ ensure
76
+ lock.flock(File::LOCK_UN) rescue nil
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Clacky
6
+ module Mcp
7
+ module OAuth
8
+ class Session
9
+ EXPIRY_SKEW = 60
10
+
11
+ class Error < StandardError; end
12
+ class NotConnectedError < Error; end
13
+
14
+ def initialize(store:, manager:, clock: nil)
15
+ @store = store
16
+ @manager = manager
17
+ @clock = clock || -> { Time.now.to_i }
18
+ @force_refresh = false
19
+ @lock = Monitor.new
20
+ end
21
+
22
+ def authorization_headers
23
+ @lock.synchronize do
24
+ grant = @store.load
25
+ raise NotConnectedError, "OAuth MCP is not connected; run `clacky mcp login SERVER`" unless grant
26
+
27
+ if @force_refresh || expiring?(grant)
28
+ grant = @manager.refresh(grant)
29
+ @store.save(grant)
30
+ @force_refresh = false
31
+ end
32
+ token = grant["access_token"].to_s
33
+ raise Error, "stored OAuth grant has no access token; log in again" if token.empty?
34
+ { "Authorization" => "Bearer #{token}" }
35
+ end
36
+ rescue NotConnectedError, Error
37
+ raise
38
+ rescue StandardError => e
39
+ raise Error, "OAuth session failed (#{e.class}); log in again"
40
+ end
41
+
42
+ def invalidate!
43
+ @lock.synchronize { @force_refresh = true }
44
+ end
45
+
46
+ def login
47
+ @lock.synchronize do
48
+ grant = @manager.login
49
+ @force_refresh = false
50
+ grant
51
+ end
52
+ end
53
+
54
+ def logout
55
+ @lock.synchronize do
56
+ @store.delete
57
+ @force_refresh = false
58
+ end
59
+ true
60
+ end
61
+
62
+ def connected?
63
+ !@store.load.nil?
64
+ rescue StandardError
65
+ false
66
+ end
67
+
68
+ def status
69
+ grant = @store.load
70
+ return { "connected" => false } unless grant
71
+ {
72
+ "connected" => true,
73
+ "expires_at" => grant["expires_at"],
74
+ "expired" => grant.fetch("expires_at", 0).to_i <= @clock.call
75
+ }
76
+ end
77
+
78
+ def inspect
79
+ "#<#{self.class} connected=#{connected?}>"
80
+ end
81
+
82
+ private def expiring?(grant)
83
+ grant.fetch("expires_at", 0).to_i <= @clock.call + EXPIRY_SKEW
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -19,6 +19,25 @@ module Clacky
19
19
  module Anthropic
20
20
  module_function
21
21
 
22
+ # Normalise a /v1/messages usage block to the codebase's canonical
23
+ # (OpenAI-style) prompt_tokens: total input including cache_read but
24
+ # excluding cache_creation.
25
+ #
26
+ # Anthropic reports input_tokens as the post-breakpoint tail only, with
27
+ # the cache buckets disjoint from it. Some /v1/messages-compatible
28
+ # gateways instead report input_tokens as the FULL input with both cache
29
+ # buckets already folded in; adding cache_read there would bill the whole
30
+ # cached prefix twice. Detect that by whether the cache buckets already
31
+ # fit inside input_tokens.
32
+ def normalise_prompt_tokens(input_tokens, cache_read, cache_creation)
33
+ cached_input = cache_read + cache_creation
34
+ if cached_input.positive? && input_tokens >= cached_input
35
+ input_tokens - cache_creation
36
+ else
37
+ input_tokens + cache_read
38
+ end
39
+ end
40
+
22
41
  # ── Message type identification ───────────────────────────────────────────
23
42
 
24
43
  # Returns true if the message is an Anthropic-native tool result stored in
@@ -169,22 +188,16 @@ module Clacky
169
188
  else data["stop_reason"]
170
189
  end
171
190
 
172
- # Anthropic native `input_tokens` counts ONLY the non-cached, freshly-billed
173
- # input — cache_read_input_tokens and cache_creation_input_tokens are
174
- # reported separately and are disjoint from input_tokens.
175
- #
176
191
  # Normalise to the codebase's canonical shape (OpenAI-style) so downstream
177
192
  # (ModelPricing.calculate_cost, CostTracker, show_token_usage) stays
178
193
  # provider-agnostic:
179
194
  #
180
- # prompt_tokens = non_cached + cache_read (OpenAI convention:
181
- # includes cache_read
182
- # but NOT cache_write;
183
- # ModelPricing does
184
- # `regular_input = prompt_tokens - cache_read`.)
195
+ # prompt_tokens = total input incl. cache_read, excl. cache_write
196
+ # (ModelPricing does
197
+ # `regular_input = prompt_tokens - cache_read`.)
185
198
  # completion_tokens = output
186
199
  # total_tokens = THIS TURN'S new compute volume
187
- # = raw_input + cache_creation + output
200
+ # = non_cached + cache_creation + output
188
201
  # (cache_read is excluded because hits are ~free /
189
202
  # already-paid-for; cache_creation IS new work this
190
203
  # turn even though it's billed at write_rate.)
@@ -199,14 +212,15 @@ module Clacky
199
212
  cache_creation = usage["cache_creation_input_tokens"].to_i
200
213
  output_tokens = usage["output_tokens"].to_i
201
214
 
202
- prompt_tokens = raw_input_tokens + cache_read
215
+ prompt_tokens = normalise_prompt_tokens(raw_input_tokens, cache_read, cache_creation)
203
216
 
204
217
  usage_data = {
205
218
  prompt_tokens: prompt_tokens,
206
219
  completion_tokens: output_tokens,
207
220
  # Per-turn new compute: what the server freshly processed this request.
208
- # Excludes cache_read (nearly free, already-paid-for).
209
- total_tokens: raw_input_tokens + cache_creation + output_tokens,
221
+ # Excludes cache_read (nearly free, already-paid-for). Derived from the
222
+ # normalised prompt_tokens so it stays correct under both conventions.
223
+ total_tokens: (prompt_tokens - cache_read) + cache_creation + output_tokens,
210
224
  # Signal to CostTracker: total_tokens above is already the per-turn
211
225
  # delta (not a running cumulative like OpenAI's). CostTracker should
212
226
  # NOT subtract previous_total when this flag is truthy.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Clacky
4
6
  # MessageHistory wraps the conversation message list and exposes
5
7
  # business-meaningful operations instead of raw array manipulation.
@@ -12,9 +14,18 @@ module Clacky
12
14
  task_id created_at system_injected session_context memory_update
13
15
  subagent_instructions subagent_result subagent_transcript token_usage
14
16
  compressed_summary chunk_path truncated transient
15
- chunk_index chunk_count
17
+ chunk_index chunk_count ext_events
16
18
  ].freeze
17
19
 
20
+ # Cap on persisted ext_events per message. These are milestone events
21
+ # (progress chatter is transient), so a handful per message is the norm —
22
+ # the cap only exists to stop a runaway extension. Oldest are dropped first.
23
+ MAX_EXT_EVENTS_PER_MESSAGE = 50
24
+
25
+ # Oversized payloads belong in a file, not in session.json (which is
26
+ # rewritten in full on every save).
27
+ MAX_EXT_EVENT_BYTES = 8 * 1024
28
+
18
29
  def initialize(messages = [])
19
30
  @messages = messages.dup
20
31
  end
@@ -85,6 +96,26 @@ module Clacky
85
96
  self
86
97
  end
87
98
 
99
+ # Append a custom extension event onto the most recent message so it is
100
+ # persisted with the session and can be replayed after a reload.
101
+ # Events are anchored to the last message: on replay they are re-emitted
102
+ # right after that message, preserving chronological order.
103
+ # No-op when the history is still empty (nothing to anchor to).
104
+ def append_ext_event(event)
105
+ msg = @messages.last
106
+ return self unless msg
107
+
108
+ if JSON.generate(event).bytesize > MAX_EXT_EVENT_BYTES
109
+ Clacky::Logger.warn("ext event #{event[:type].inspect} dropped: payload exceeds #{MAX_EXT_EVENT_BYTES} bytes")
110
+ return self
111
+ end
112
+
113
+ list = (msg[:ext_events] ||= [])
114
+ list.shift while list.size >= MAX_EXT_EVENTS_PER_MESSAGE
115
+ list << deep_sanitize_utf8(event)
116
+ self
117
+ end
118
+
88
119
  # Mutate the last message matching the predicate lambda in-place.
89
120
  # Used by execute_skill_with_subagent to update instruction messages.
90
121
  def mutate_last_matching(predicate, &block)