openclacky 1.1.6 → 1.2.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +1 -1
  4. data/CONTRIBUTING.md +92 -0
  5. data/README.md +10 -0
  6. data/README_CN.md +10 -0
  7. data/ROADMAP.md +29 -0
  8. data/docs/billing-system.md +340 -0
  9. data/docs/mcp-architecture.md +114 -0
  10. data/docs/mcp.example.json +22 -0
  11. data/lib/clacky/agent/cost_tracker.rb +37 -0
  12. data/lib/clacky/agent/llm_caller.rb +0 -1
  13. data/lib/clacky/agent/session_serializer.rb +2 -11
  14. data/lib/clacky/agent/skill_manager.rb +73 -26
  15. data/lib/clacky/agent/system_prompt_builder.rb +0 -5
  16. data/lib/clacky/agent/time_machine.rb +6 -0
  17. data/lib/clacky/agent.rb +26 -1
  18. data/lib/clacky/agent_config.rb +9 -19
  19. data/lib/clacky/billing/billing_record.rb +67 -0
  20. data/lib/clacky/billing/billing_store.rb +193 -0
  21. data/lib/clacky/cli.rb +108 -6
  22. data/lib/clacky/default_skills/browser-setup/SKILL.md +26 -4
  23. data/lib/clacky/default_skills/mcp-manager/SKILL.md +343 -0
  24. data/lib/clacky/idle_compression_timer.rb +4 -2
  25. data/lib/clacky/mcp/client.rb +204 -0
  26. data/lib/clacky/mcp/http_transport.rb +155 -0
  27. data/lib/clacky/mcp/registry.rb +229 -0
  28. data/lib/clacky/mcp/skill_provider.rb +75 -0
  29. data/lib/clacky/mcp/stdio_transport.rb +112 -0
  30. data/lib/clacky/mcp/transport.rb +23 -0
  31. data/lib/clacky/mcp/virtual_skill.rb +131 -0
  32. data/lib/clacky/message_history.rb +0 -1
  33. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +2 -35
  34. data/lib/clacky/server/http_server.rb +519 -15
  35. data/lib/clacky/server/server_master.rb +8 -14
  36. data/lib/clacky/server/session_registry.rb +24 -2
  37. data/lib/clacky/server/web_ui_controller.rb +4 -0
  38. data/lib/clacky/session_manager.rb +41 -12
  39. data/lib/clacky/skill.rb +1 -5
  40. data/lib/clacky/skill_loader.rb +36 -5
  41. data/lib/clacky/tools/browser.rb +217 -38
  42. data/lib/clacky/tools/trash_manager.rb +154 -3
  43. data/lib/clacky/ui2/components/command_suggestions.rb +6 -2
  44. data/lib/clacky/ui_interface.rb +1 -0
  45. data/lib/clacky/utils/model_pricing.rb +11 -7
  46. data/lib/clacky/utils/trash_directory.rb +37 -6
  47. data/lib/clacky/version.rb +1 -1
  48. data/lib/clacky/web/app.css +2907 -1764
  49. data/lib/clacky/web/app.js +84 -10
  50. data/lib/clacky/web/billing.js +275 -0
  51. data/lib/clacky/web/brand.js +3 -0
  52. data/lib/clacky/web/i18n.js +242 -24
  53. data/lib/clacky/web/index.html +351 -134
  54. data/lib/clacky/web/mcp.js +328 -0
  55. data/lib/clacky/web/sessions.js +193 -11
  56. data/lib/clacky/web/settings.js +686 -174
  57. data/lib/clacky/web/sidebar.js +2 -0
  58. data/lib/clacky/web/trash.js +323 -60
  59. data/lib/clacky/web/ws-dispatcher.js +14 -1
  60. data/lib/clacky.rb +4 -0
  61. data/scripts/install.ps1 +23 -11
  62. metadata +30 -10
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "net/http"
6
+ require "uri"
7
+ require "securerandom"
8
+
9
+ require_relative "transport"
10
+
11
+ module Clacky
12
+ module Mcp
13
+ # MCP streamable-http transport (spec 2025-03-26).
14
+ #
15
+ # One endpoint URL handles both client→server (POST) and server→client (SSE).
16
+ # We POST every JSON-RPC message; the server may respond with either:
17
+ # - application/json → single response, deliver immediately
18
+ # - text/event-stream → one or more "data:" SSE events, each a JSON-RPC msg
19
+ #
20
+ # Session tracking: the server returns Mcp-Session-Id on the initialize
21
+ # response; we echo it on every subsequent request.
22
+ class HttpTransport < Transport
23
+ DEFAULT_OPEN_TIMEOUT = 10
24
+ DEFAULT_READ_TIMEOUT = 120
25
+
26
+ def initialize(name:, url:, headers: {}, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
27
+ @name = name
28
+ @uri = URI.parse(url)
29
+ raise TransportError, "MCP server '#{name}' url is not http(s): #{url}" unless %w[http https].include?(@uri.scheme)
30
+
31
+ @extra_headers = (headers || {}).transform_keys(&:to_s).transform_values(&:to_s)
32
+ @open_timeout = open_timeout
33
+ @read_timeout = read_timeout
34
+
35
+ @session_id = nil
36
+ @on_message = nil
37
+ @lock = Monitor.new
38
+ @alive = false
39
+ @last_error = nil
40
+ end
41
+
42
+ def start
43
+ @alive = true
44
+ self
45
+ end
46
+
47
+ def stop
48
+ @alive = false
49
+ end
50
+
51
+ def alive?
52
+ @alive
53
+ end
54
+
55
+ def send_message(payload)
56
+ raise TransportError, "transport stopped" unless @alive
57
+
58
+ body = JSON.generate(payload)
59
+ is_request = payload.is_a?(Hash) && payload.key?(:id) || (payload.is_a?(Hash) && payload.key?("id"))
60
+
61
+ Thread.new do
62
+ begin
63
+ dispatch_post(body, is_request: is_request)
64
+ rescue StandardError => e
65
+ @last_error = e
66
+ @on_message&.call({
67
+ "id" => payload[:id] || payload["id"],
68
+ "error" => { "code" => -32000, "message" => "HTTP transport error: #{e.message}" }
69
+ })
70
+ end
71
+ end
72
+ end
73
+
74
+ def on_message(&blk)
75
+ @on_message = blk
76
+ end
77
+
78
+ def stderr_tail(bytes: 4096)
79
+ @last_error ? "last error: #{@last_error.class}: #{@last_error.message}" : ""
80
+ end
81
+
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
+
88
+ req = Net::HTTP::Post.new(@uri.request_uri)
89
+ req["Content-Type"] = "application/json"
90
+ req["Accept"] = "application/json, text/event-stream"
91
+ req["MCP-Protocol-Version"] = Client::PROTOCOL_VERSION if defined?(Client::PROTOCOL_VERSION)
92
+ @lock.synchronize { req["Mcp-Session-Id"] = @session_id if @session_id }
93
+ @extra_headers.each { |k, v| req[k] = v }
94
+ req.body = body
95
+
96
+ http.request(req) do |res|
97
+ if (sid = res["Mcp-Session-Id"])
98
+ @lock.synchronize { @session_id = sid }
99
+ end
100
+
101
+ status = res.code.to_i
102
+ if status == 202
103
+ return
104
+ end
105
+ if status >= 400
106
+ text = res.read_body.to_s
107
+ raise TransportError, "HTTP #{status} from MCP server '#{@name}': #{text[0, 500]}"
108
+ end
109
+
110
+ ctype = (res["Content-Type"] || "").downcase
111
+ if ctype.include?("text/event-stream")
112
+ consume_sse(res)
113
+ else
114
+ text = res.read_body.to_s
115
+ return if text.strip.empty?
116
+ begin
117
+ msg = JSON.parse(text)
118
+ rescue JSON::ParserError => e
119
+ raise TransportError, "invalid JSON from MCP server '#{@name}': #{e.message}"
120
+ end
121
+ deliver(msg)
122
+ end
123
+ end
124
+ end
125
+
126
+ private def consume_sse(res)
127
+ buffer = String.new
128
+ res.read_body do |chunk|
129
+ buffer << chunk
130
+ while (idx = buffer.index("\n\n"))
131
+ event = buffer.slice!(0, idx + 2)
132
+ data_lines = event.each_line.map(&:chomp).select { |l| l.start_with?("data:") }
133
+ next if data_lines.empty?
134
+ payload = data_lines.map { |l| l.sub(/\Adata:\s?/, "") }.join("\n")
135
+ next if payload.empty?
136
+ begin
137
+ msg = JSON.parse(payload)
138
+ rescue JSON::ParserError
139
+ next
140
+ end
141
+ deliver(msg)
142
+ end
143
+ end
144
+ end
145
+
146
+ private def deliver(msg)
147
+ if msg.is_a?(Array)
148
+ msg.each { |m| @on_message&.call(m) }
149
+ else
150
+ @on_message&.call(msg)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+ require "monitor"
6
+
7
+ require_relative "client"
8
+ require_relative "virtual_skill"
9
+
10
+ module Clacky
11
+ module Mcp
12
+ # Central registry for MCP servers configured by the user.
13
+ #
14
+ # Responsibilities:
15
+ # - Load ~/.clacky/mcp.json (or project .clacky/mcp.json) on demand.
16
+ # - For each declared server, expose a VirtualSkill so the main agent
17
+ # sees it as a one-line capability in the AVAILABLE SKILLS section.
18
+ # No tool schemas leak into the main context.
19
+ # - Lazily spawn the server process the first time invoke_skill('mcp:xxx')
20
+ # happens, cache the connection, and reap idle servers after a timeout.
21
+ # - Provide a single call_tool entry point for Tools::McpCall to dispatch
22
+ # into.
23
+ class Registry
24
+ # User-facing config files, in priority order (later wins).
25
+ # Resolved at load time (not at constant evaluation) so HOME changes — e.g.
26
+ # in tests using stub_const or Dir.mktmpdir + ENV — are honored.
27
+ private def global_config_paths
28
+ [File.join(Dir.home, ".clacky", "mcp.json")]
29
+ end
30
+
31
+ # How long an MCP server may sit idle before we reap it. Vital for the
32
+ # "no gateway" promise: we never keep stale processes around.
33
+ DEFAULT_IDLE_TIMEOUT = 300 # 5 min
34
+
35
+ # How long to wait for tools/list during cold metadata collection.
36
+ DESCRIPTION_FETCH_TIMEOUT = 20
37
+
38
+ attr_reader :servers
39
+
40
+ def initialize(working_dir: nil, idle_timeout: DEFAULT_IDLE_TIMEOUT)
41
+ @working_dir = working_dir
42
+ @idle_timeout = idle_timeout
43
+ @servers = {} # name => spec hash
44
+ @clients = {} # name => Client (only when started)
45
+ @virtual_skills_cache = nil
46
+ @lock = Monitor.new
47
+ @reaper_thread = nil
48
+
49
+ load_config
50
+ start_reaper
51
+ end
52
+
53
+ # Reload mcp.json (e.g. user added a server) and invalidate caches.
54
+ # Existing live clients survive; only stopped/removed servers get cleaned.
55
+ def reload
56
+ @lock.synchronize do
57
+ old_names = @servers.keys
58
+ @servers = {}
59
+ load_config
60
+ @virtual_skills_cache = nil
61
+
62
+ (old_names - @servers.keys).each do |gone|
63
+ @clients.delete(gone)&.stop
64
+ end
65
+ end
66
+ end
67
+
68
+ # Map of server name -> VirtualSkill. Cached because rebuilding it triggers
69
+ # tools/list against every cold server, which we want to do at most once
70
+ # per process.
71
+ #
72
+ # Implementation note: we do NOT pre-spawn servers here. We need their
73
+ # tool list to populate the VirtualSkill body, but we only fetch it the
74
+ # first time the *subagent* actually fires up. For the system-prompt
75
+ # description we use the user-provided "description" field from mcp.json,
76
+ # falling back to a placeholder. This keeps app startup zero-cost.
77
+ def virtual_skills
78
+ @lock.synchronize do
79
+ return @virtual_skills_cache.values if @virtual_skills_cache
80
+
81
+ @virtual_skills_cache = {}
82
+ @servers.each do |name, spec|
83
+ @virtual_skills_cache[name] = VirtualSkill.new(
84
+ server_name: name,
85
+ description: spec["description"] || default_description_for(name)
86
+ )
87
+ end
88
+ @virtual_skills_cache.values
89
+ end
90
+ end
91
+
92
+ # Return a fresh VirtualSkill for a server. The HttpServer's MCP tools
93
+ # endpoint uses this to satisfy probe requests; tool schemas are no
94
+ # longer attached to the skill itself — clients fetch them separately
95
+ # via /api/mcp/:name/tools.
96
+ # @param server_name [String]
97
+ # @return [VirtualSkill, nil]
98
+ def virtual_skill_for(server_name)
99
+ return nil unless @servers.key?(server_name)
100
+
101
+ spec = @servers[server_name]
102
+ VirtualSkill.new(
103
+ server_name: server_name,
104
+ description: spec["description"] || default_description_for(server_name)
105
+ )
106
+ end
107
+
108
+ # Fetch the live tool list (and lazily cold-start the server). Used by
109
+ # the HttpServer for /api/mcp/:name/tools and /api/mcp/:name/probe.
110
+ def tool_definitions(server_name)
111
+ client = ensure_started(server_name)
112
+ return [] unless client
113
+
114
+ client.tool_definitions
115
+ end
116
+
117
+ # Execute a tool call against an MCP server. Used by Tools::McpCall.
118
+ # @return [Hash] MCP `tools/call` result
119
+ def call_tool(server_name, tool_name, arguments)
120
+ client = ensure_started(server_name)
121
+ raise Mcp::Client::TransportError, "MCP server '#{server_name}' is not configured" unless client
122
+
123
+ client.call_tool(tool_name, arguments)
124
+ end
125
+
126
+ # Has the user configured any MCP servers?
127
+ def any?
128
+ !@servers.empty?
129
+ end
130
+
131
+ def configured?(server_name)
132
+ @servers.key?(server_name)
133
+ end
134
+
135
+ # Stop all live MCP server processes. Safe to call from at_exit hooks
136
+ # and on agent shutdown.
137
+ def shutdown
138
+ @lock.synchronize do
139
+ @reaper_thread&.kill rescue nil
140
+ @reaper_thread = nil
141
+ @clients.each_value { |c| c.stop rescue nil }
142
+ @clients.clear
143
+ end
144
+ end
145
+
146
+ # Spawn (if needed) and return the client for a server. Returns nil if the
147
+ # server is not configured. Raises Mcp::Client::TransportError if the
148
+ # process refuses to start.
149
+ private def ensure_started(server_name)
150
+ spec = @servers[server_name]
151
+ return nil unless spec
152
+
153
+ @lock.synchronize do
154
+ existing = @clients[server_name]
155
+ return existing if existing&.started?
156
+
157
+ client = Client.from_spec(server_name, spec)
158
+ client.start
159
+ @clients[server_name] = client
160
+ client
161
+ end
162
+ end
163
+
164
+ private def load_config
165
+ paths = global_config_paths
166
+ if @working_dir
167
+ paths = paths + [File.join(@working_dir, ".clacky", "mcp.json")]
168
+ end
169
+
170
+ paths.each do |path|
171
+ next unless File.exist?(path)
172
+
173
+ begin
174
+ data = JSON.parse(File.read(path))
175
+ rescue JSON::ParserError => e
176
+ Clacky::Logger.warn("Skipping malformed MCP config #{path}: #{e.message}") if defined?(Clacky::Logger)
177
+ next
178
+ end
179
+
180
+ servers = data["mcpServers"] || data["servers"] || {}
181
+ servers.each do |name, spec|
182
+ next unless spec.is_a?(Hash)
183
+ next if spec["disabled"] == true
184
+
185
+ type = (spec["type"] || (spec["url"] ? "http" : "stdio")).to_s
186
+ case type
187
+ when "stdio"
188
+ next unless spec["command"]
189
+ when "http", "streamable-http"
190
+ next unless spec["url"]
191
+ else
192
+ next
193
+ end
194
+
195
+ @servers[name.to_s] = spec
196
+ end
197
+ end
198
+ end
199
+
200
+ private def default_description_for(name)
201
+ "MCP server '#{name}'. Use this skill to delegate any task that this server " \
202
+ "can handle. The subagent will see the server's full tool list at fork time."
203
+ end
204
+
205
+ private def start_reaper
206
+ return if @idle_timeout.nil? || @idle_timeout <= 0
207
+
208
+ @reaper_thread = Thread.new do
209
+ loop do
210
+ sleep [@idle_timeout / 5, 30].min
211
+ now = Time.now
212
+ @lock.synchronize do
213
+ @clients.each do |name, client|
214
+ next unless client.last_used_at
215
+ if now - client.last_used_at > @idle_timeout
216
+ client.stop
217
+ @clients.delete(name)
218
+ end
219
+ end
220
+ end
221
+ end
222
+ rescue StandardError
223
+ # Reaper thread must never crash the main agent. Best-effort.
224
+ end
225
+ @reaper_thread.name = "mcp-reaper" if @reaper_thread.respond_to?(:name=)
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "virtual_skill"
6
+
7
+ module Clacky
8
+ module Mcp
9
+ # Static, read-only provider that translates ~/.clacky/mcp.json (and the
10
+ # project-level override) into VirtualSkill instances for the SkillLoader.
11
+ #
12
+ # Unlike Mcp::Registry, this class never spawns server processes, never
13
+ # talks JSON-RPC, and holds no mutable state. All actual MCP traffic flows
14
+ # through the local Clacky HTTP API (/api/mcp/:server/tools and /call),
15
+ # which subagents reach via curl. This keeps agents process-light and
16
+ # decouples skill discovery from server lifecycle.
17
+ class SkillProvider
18
+ def initialize(working_dir: nil)
19
+ @working_dir = working_dir
20
+ end
21
+
22
+ def virtual_skills
23
+ load_servers.map do |name, spec|
24
+ VirtualSkill.new(
25
+ server_name: name,
26
+ description: spec["description"] || default_description_for(name)
27
+ )
28
+ end
29
+ end
30
+
31
+ private def load_servers
32
+ servers = {}
33
+ config_paths.each do |path|
34
+ next unless File.exist?(path)
35
+
36
+ begin
37
+ data = JSON.parse(File.read(path))
38
+ rescue JSON::ParserError => e
39
+ Clacky::Logger.warn("Skipping malformed MCP config #{path}: #{e.message}") if defined?(Clacky::Logger)
40
+ next
41
+ end
42
+
43
+ (data["mcpServers"] || data["servers"] || {}).each do |name, spec|
44
+ next unless spec.is_a?(Hash)
45
+ next if spec["disabled"] == true
46
+
47
+ type = (spec["type"] || (spec["url"] ? "http" : "stdio")).to_s
48
+ case type
49
+ when "stdio"
50
+ next unless spec["command"]
51
+ when "http", "streamable-http"
52
+ next unless spec["url"]
53
+ else
54
+ next
55
+ end
56
+
57
+ servers[name.to_s] = spec
58
+ end
59
+ end
60
+ servers
61
+ end
62
+
63
+ private def config_paths
64
+ paths = [File.join(Dir.home, ".clacky", "mcp.json")]
65
+ paths << File.join(@working_dir, ".clacky", "mcp.json") if @working_dir
66
+ paths
67
+ end
68
+
69
+ private def default_description_for(name)
70
+ "MCP server '#{name}'. Required entry point for any operation against " \
71
+ "this server — invoke this skill so a subagent runs the calls."
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "monitor"
6
+
7
+ require_relative "transport"
8
+
9
+ module Clacky
10
+ module Mcp
11
+ class StdioTransport < Transport
12
+ def initialize(name:, command:, args: [], env: {}, cwd: nil)
13
+ @name = name
14
+ @command = command
15
+ @args = Array(args)
16
+ @env = env || {}
17
+ @cwd = cwd
18
+
19
+ @stdin = @stdout = @stderr = nil
20
+ @wait_thr = nil
21
+ @reader_thr = nil
22
+ @on_message = nil
23
+ @lock = Monitor.new
24
+ @stderr_buf = String.new
25
+ end
26
+
27
+ def start
28
+ full_env = ENV.to_h.merge(@env.transform_keys(&:to_s).transform_values(&:to_s))
29
+ opts = { unsetenv_others: false }
30
+ opts[:chdir] = @cwd if @cwd && File.directory?(@cwd)
31
+
32
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(full_env, @command, *@args, opts)
33
+ @stdin.sync = true
34
+
35
+ Thread.new do
36
+ @stderr.each_line do |line|
37
+ @lock.synchronize do
38
+ @stderr_buf << line
39
+ @stderr_buf.replace(@stderr_buf[-32_768, 32_768] || @stderr_buf) if @stderr_buf.bytesize > 65_536
40
+ end
41
+ end
42
+ rescue IOError
43
+ end
44
+
45
+ start_reader
46
+ self
47
+ rescue Errno::ENOENT => e
48
+ raise TransportError, "MCP server '#{@name}' command not found: #{@command} (#{e.message})"
49
+ end
50
+
51
+ def stop
52
+ @lock.synchronize do
53
+ return unless @wait_thr&.alive?
54
+ begin
55
+ Process.kill("TERM", @wait_thr.pid)
56
+ rescue Errno::ESRCH, Errno::EPERM
57
+ end
58
+ deadline = Time.now + 2
59
+ sleep 0.05 while @wait_thr.alive? && Time.now < deadline
60
+ if @wait_thr.alive?
61
+ begin
62
+ Process.kill("KILL", @wait_thr.pid)
63
+ rescue Errno::ESRCH, Errno::EPERM
64
+ end
65
+ end
66
+ ensure
67
+ [@stdin, @stdout, @stderr].each { |io| io&.close rescue nil }
68
+ @reader_thr&.kill rescue nil
69
+ end
70
+ end
71
+
72
+ def alive?
73
+ !!(@wait_thr && @wait_thr.alive?)
74
+ end
75
+
76
+ def send_message(payload)
77
+ line = JSON.generate(payload) + "\n"
78
+ @lock.synchronize do
79
+ raise TransportError, "MCP server '#{@name}' stdin closed" if @stdin.nil? || @stdin.closed?
80
+ @stdin.write(line)
81
+ end
82
+ rescue Errno::EPIPE => e
83
+ raise TransportError, "MCP server '#{@name}' stdin pipe broken: #{e.message}"
84
+ end
85
+
86
+ def on_message(&blk)
87
+ @on_message = blk
88
+ end
89
+
90
+ def stderr_tail(bytes: 4096)
91
+ @lock.synchronize { @stderr_buf[-bytes, bytes] || @stderr_buf.dup }
92
+ end
93
+
94
+ private def start_reader
95
+ @reader_thr = Thread.new do
96
+ @stdout.each_line do |line|
97
+ line = line.strip
98
+ next if line.empty?
99
+ begin
100
+ msg = JSON.parse(line)
101
+ rescue JSON::ParserError
102
+ next
103
+ end
104
+ @on_message&.call(msg)
105
+ end
106
+ rescue IOError
107
+ @on_message&.call({ "__transport_closed__" => true })
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Mcp
5
+ # Abstract transport. Concrete transports must implement:
6
+ # #start -> open the channel (spawn process / open connection)
7
+ # #stop -> close everything; must be idempotent
8
+ # #alive? -> whether the channel is healthy
9
+ # #send_message(h) -> serialize and write one JSON-RPC message
10
+ # #on_message(&b) -> register callback invoked with each parsed inbound JSON message
11
+ # #stderr_tail(bytes:) -> recent diagnostic text (may be empty)
12
+ class Transport
13
+ class TransportError < StandardError; end
14
+
15
+ def start; raise NotImplementedError; end
16
+ def stop; raise NotImplementedError; end
17
+ def alive?; raise NotImplementedError; end
18
+ def send_message(_); raise NotImplementedError; end
19
+ def on_message(&_blk); raise NotImplementedError; end
20
+ def stderr_tail(bytes: 4096); ""; end
21
+ end
22
+ end
23
+ end