rubyn-code 0.2.2 → 0.4.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. metadata +83 -1
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "session/resume" JSON-RPC request.
7
+ #
8
+ # Loads a previously persisted session from SessionPersistence and
9
+ # pre-populates the PromptHandler's conversation cache so that the
10
+ # next prompt continues from where the session left off.
11
+ class SessionResumeHandler
12
+ def initialize(server)
13
+ @server = server
14
+ end
15
+
16
+ def call(params)
17
+ session_id = params['sessionId']
18
+ return { 'resumed' => false, 'error' => 'Missing sessionId' } unless session_id
19
+
20
+ persistence = @server.session_persistence
21
+ return { 'resumed' => false, 'error' => 'Session persistence not available' } unless persistence
22
+
23
+ data = persistence.load_session(session_id)
24
+ return { 'resumed' => false, 'error' => 'Session not found' } unless data
25
+
26
+ messages = data[:messages] || []
27
+
28
+ # Pre-populate the prompt handler's conversation cache so the next
29
+ # prompt picks up from the restored history.
30
+ prompt = @server.handler_instance(:prompt)
31
+ if prompt
32
+ conversation = Agent::Conversation.new
33
+ messages.each { |msg| conversation.messages << msg }
34
+ prompt.inject_conversation(session_id, conversation)
35
+ end
36
+
37
+ { 'resumed' => true, 'sessionId' => session_id, 'messages' => messages }
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "shutdown" JSON-RPC request.
7
+ #
8
+ # Triggers session persistence, signals the server to stop its
9
+ # read loop, and returns confirmation.
10
+ class ShutdownHandler
11
+ def initialize(server)
12
+ @server = server
13
+ end
14
+
15
+ def call(_params)
16
+ warn '[ShutdownHandler] shutdown requested'
17
+
18
+ save_session!
19
+ @server.stop!
20
+
21
+ { 'shutdown' => true }
22
+ end
23
+
24
+ private
25
+
26
+ def save_session!
27
+ return unless defined?(RubynCode::Memory::SessionPersistence)
28
+
29
+ persistence = @server.session_persistence
30
+ persistence&.save
31
+ rescue StandardError => e
32
+ warn "[ShutdownHandler] session save failed: #{e.message}"
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'handlers/initialize_handler'
4
+ require_relative 'handlers/prompt_handler'
5
+ require_relative 'handlers/cancel_handler'
6
+ require_relative 'handlers/review_handler'
7
+ require_relative 'handlers/approve_tool_use_handler'
8
+ require_relative 'handlers/accept_edit_handler'
9
+ require_relative 'handlers/shutdown_handler'
10
+ require_relative 'handlers/config_get_handler'
11
+ require_relative 'handlers/config_set_handler'
12
+ require_relative 'handlers/models_list_handler'
13
+ require_relative 'handlers/session_reset_handler'
14
+ require_relative 'handlers/session_list_handler'
15
+ require_relative 'handlers/session_resume_handler'
16
+ require_relative 'handlers/session_fork_handler'
17
+
18
+ module RubynCode
19
+ module IDE
20
+ module Handlers
21
+ # Method name => Handler class mapping.
22
+ REGISTRY = {
23
+ 'initialize' => InitializeHandler,
24
+ 'prompt' => PromptHandler,
25
+ 'cancel' => CancelHandler,
26
+ 'review' => ReviewHandler,
27
+ 'approveToolUse' => ApproveToolUseHandler,
28
+ 'acceptEdit' => AcceptEditHandler,
29
+ 'shutdown' => ShutdownHandler,
30
+ 'config/get' => ConfigGetHandler,
31
+ 'config/set' => ConfigSetHandler,
32
+ 'models/list' => ModelsListHandler,
33
+ 'session/reset' => SessionResetHandler,
34
+ 'session/list' => SessionListHandler,
35
+ 'session/resume' => SessionResumeHandler,
36
+ 'session/fork' => SessionForkHandler
37
+ }.freeze
38
+
39
+ # Short name => method name mapping (for handler_instance lookups).
40
+ SHORT_NAMES = {
41
+ prompt: 'prompt',
42
+ cancel: 'cancel',
43
+ review: 'review',
44
+ approve_tool_use: 'approveToolUse',
45
+ accept_edit: 'acceptEdit',
46
+ shutdown: 'shutdown',
47
+ initialize: 'initialize',
48
+ config_get: 'config/get',
49
+ config_set: 'config/set',
50
+ models_list: 'models/list',
51
+ session_reset: 'session/reset',
52
+ session_list: 'session/list',
53
+ session_resume: 'session/resume',
54
+ session_fork: 'session/fork'
55
+ }.freeze
56
+
57
+ # Register all handlers on the given server instance.
58
+ #
59
+ # @param server [RubynCode::IDE::Server] the IDE server
60
+ def self.register_all(server)
61
+ instances = {}
62
+
63
+ REGISTRY.each do |method, handler_class|
64
+ handler = handler_class.new(server)
65
+ instances[method] = handler
66
+
67
+ server.on(method) do |params, _id|
68
+ handler.call(params)
69
+ end
70
+ end
71
+
72
+ server.handler_instances = instances
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ # JSON-RPC 2.0 protocol layer for the IDE server.
8
+ # Pure data — no side effects, no I/O beyond JSON serialisation.
9
+ module Protocol
10
+ JSONRPC_VERSION = '2.0'
11
+
12
+ # ── Standard JSON-RPC 2.0 error codes ──────────────────────────────
13
+ PARSE_ERROR = -32_700
14
+ INVALID_REQUEST = -32_600
15
+ METHOD_NOT_FOUND = -32_601
16
+ INVALID_PARAMS = -32_602
17
+ INTERNAL_ERROR = -32_603
18
+
19
+ # ── Custom error codes ─────────────────────────────────────────────
20
+ AGENT_BUSY = -1
21
+ SESSION_NOT_FOUND = -2
22
+ BUDGET_EXCEEDED = -3
23
+
24
+ module_function
25
+
26
+ # Parse a JSON string into a request hash.
27
+ # Returns either a valid request hash or an error response hash.
28
+ def parse(line) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- JSON-RPC validation checks
29
+ begin
30
+ data = JSON.parse(line)
31
+ rescue JSON::ParserError
32
+ return error(nil, PARSE_ERROR, 'Parse error: invalid JSON')
33
+ end
34
+
35
+ return error(nil, INVALID_REQUEST, 'Invalid request: expected JSON object') unless data.is_a?(Hash)
36
+
37
+ unless data['jsonrpc'] == JSONRPC_VERSION
38
+ return error(data['id'], INVALID_REQUEST, 'Invalid request: missing or wrong "jsonrpc" version')
39
+ end
40
+
41
+ # Response objects (containing "result" or "error") are valid
42
+ # JSON-RPC 2.0 messages that don't carry a "method".
43
+ is_response = data.key?('result') || data.key?('error')
44
+
45
+ unless is_response || data['method'].is_a?(String)
46
+ return error(data['id'], INVALID_REQUEST, 'Invalid request: "method" must be a string')
47
+ end
48
+
49
+ if data.key?('params') && !data['params'].is_a?(Hash) && !data['params'].is_a?(Array)
50
+ return error(data['id'], INVALID_PARAMS, 'Invalid params: "params" must be an object or array')
51
+ end
52
+
53
+ data
54
+ end
55
+
56
+ # Build a success response hash.
57
+ def response(id, result)
58
+ {
59
+ 'jsonrpc' => JSONRPC_VERSION,
60
+ 'id' => id,
61
+ 'result' => stringify_keys_deep(result)
62
+ }
63
+ end
64
+
65
+ # Build an error response hash.
66
+ def error(id, code, message)
67
+ {
68
+ 'jsonrpc' => JSONRPC_VERSION,
69
+ 'id' => id,
70
+ 'error' => {
71
+ 'code' => code,
72
+ 'message' => message
73
+ }
74
+ }
75
+ end
76
+
77
+ # Build a notification hash (no id).
78
+ def notification(method, params)
79
+ {
80
+ 'jsonrpc' => JSONRPC_VERSION,
81
+ 'method' => method,
82
+ 'params' => stringify_keys_deep(params)
83
+ }
84
+ end
85
+
86
+ # Serialise a hash to a JSON string terminated by a newline.
87
+ def serialize(hash)
88
+ "#{JSON.generate(hash)}\n"
89
+ end
90
+
91
+ # ── Helpers ────────────────────────────────────────────────────────
92
+
93
+ # Recursively convert symbol keys to strings so every hash that
94
+ # leaves this module uses string keys for JSON compatibility.
95
+ def stringify_keys_deep(obj)
96
+ case obj
97
+ when Hash
98
+ obj.each_with_object({}) do |(k, v), memo|
99
+ memo[k.to_s] = stringify_keys_deep(v)
100
+ end
101
+ when Array
102
+ obj.map { |v| stringify_keys_deep(v) }
103
+ else
104
+ obj
105
+ end
106
+ end
107
+
108
+ private_class_method :stringify_keys_deep
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'protocol'
5
+ require_relative 'client'
6
+ require_relative 'handlers'
7
+
8
+ module RubynCode
9
+ module IDE
10
+ # JSON-RPC 2.0 server for the VS Code extension.
11
+ #
12
+ # Reads newline-delimited JSON from $stdin, dispatches each request
13
+ # to a handler, and writes JSON-RPC responses/notifications to $stdout.
14
+ # All debug output goes to $stderr — never protocol data.
15
+ #
16
+ # Processes one request at a time on the main thread.
17
+ class Server
18
+ # Attributes set by handlers during the session lifecycle.
19
+ attr_accessor :workspace_path, :extension_version, :client_capabilities,
20
+ :session_persistence, :handler_instances, :tool_output_adapter,
21
+ :permission_mode
22
+ attr_reader :ide_client
23
+
24
+ def initialize(permission_mode: :default, yolo: false)
25
+ @permission_mode = yolo ? :bypass : permission_mode.to_sym
26
+ @running = false
27
+ @write_mutex = Mutex.new
28
+ @handlers = {}
29
+ @handler_instances = {}
30
+ @workspace_path = nil
31
+ @extension_version = nil
32
+ @client_capabilities = {}
33
+ @session_persistence = nil
34
+ @tool_output_adapter = nil
35
+ @ide_client = Client.new(self)
36
+
37
+ Handlers.register_all(self)
38
+ end
39
+
40
+ # Backward-compatible reader: true when permission_mode is :bypass.
41
+ def yolo
42
+ @permission_mode == :bypass
43
+ end
44
+
45
+ def run
46
+ @running = true
47
+ setup_signal_traps!
48
+
49
+ warn "[IDE::Server] started (pid=#{Process.pid})"
50
+ $stdout.sync = true
51
+
52
+ read_loop
53
+ ensure
54
+ graceful_shutdown!
55
+ end
56
+
57
+ # ── Public helpers ──────────────────────────────────────────────
58
+
59
+ # Send a JSON-RPC notification (no id) to stdout.
60
+ def notify(method, params = {})
61
+ write(Protocol.notification(method, params))
62
+ end
63
+
64
+ # Register a handler for a given JSON-RPC method.
65
+ # The block receives (params, id) and must return a result hash.
66
+ def on(method, &block)
67
+ @handlers[method] = block
68
+ end
69
+
70
+ # Look up a handler instance by its short name (e.g. :prompt, :cancel).
71
+ # Returns nil if the handler is not registered.
72
+ def handler_instance(short_name)
73
+ method_name = Handlers::SHORT_NAMES[short_name.to_sym]
74
+ return nil unless method_name
75
+
76
+ @handler_instances[method_name]
77
+ end
78
+
79
+ # Signal the server to stop its read loop.
80
+ def stop!
81
+ @running = false
82
+ end
83
+
84
+ private
85
+
86
+ # ── Main loop ───────────────────────────────────────────────────
87
+
88
+ def read_loop
89
+ while @running
90
+ line = $stdin.gets
91
+ break if line.nil? # EOF — client disconnected
92
+
93
+ line = line.strip
94
+ next if line.empty?
95
+
96
+ handle_line(line)
97
+ end
98
+ end
99
+
100
+ def handle_line(line)
101
+ msg = Protocol.parse(line)
102
+
103
+ # Protocol.parse returns an error response hash when parsing fails.
104
+ if msg.key?('error')
105
+ write(msg)
106
+ return
107
+ end
108
+
109
+ dispatch(msg)
110
+ rescue StandardError => e
111
+ warn "[IDE::Server] error handling message: #{e.message}"
112
+ warn e.backtrace&.first(5)&.join("\n")
113
+
114
+ id = msg.is_a?(Hash) ? msg['id'] : nil
115
+ write(Protocol.error(id, Protocol::INTERNAL_ERROR, "Internal error: #{e.message}"))
116
+ end
117
+
118
+ # ── Dispatch ────────────────────────────────────────────────────
119
+
120
+ def dispatch(msg)
121
+ # Response messages from the extension (for our outbound requests via ide_client).
122
+ # These have id + (result or error) but no method.
123
+ if !msg.key?('method') && msg.key?('id') && (msg.key?('result') || msg.key?('error'))
124
+ @ide_client.resolve(
125
+ msg['id'],
126
+ result: msg['result'],
127
+ error: msg['error']
128
+ )
129
+ return
130
+ end
131
+
132
+ method = msg['method']
133
+ params = msg['params'] || {}
134
+ id = msg['id']
135
+
136
+ handler = @handlers[method]
137
+
138
+ unless handler
139
+ write(Protocol.error(id, Protocol::METHOD_NOT_FOUND, "Method not found: #{method}")) if id
140
+ return
141
+ end
142
+
143
+ result = handler.call(params, id)
144
+
145
+ # Only send a response for requests (those with an id).
146
+ # Notifications (no id) do not get responses.
147
+ write(Protocol.response(id, result)) if id
148
+ end
149
+
150
+ # ── Wire output ─────────────────────────────────────────────────
151
+
152
+ def write(hash)
153
+ serialized = Protocol.serialize(hash)
154
+ @write_mutex.synchronize do
155
+ $stdout.write(serialized)
156
+ $stdout.flush
157
+ end
158
+ end
159
+
160
+ # ── Signal handling ─────────────────────────────────────────────
161
+
162
+ def setup_signal_traps!
163
+ %w[TERM INT].each do |sig|
164
+ trap(sig) do
165
+ warn "[IDE::Server] received SIG#{sig}, shutting down"
166
+ @running = false
167
+ end
168
+ end
169
+ end
170
+
171
+ # ── Shutdown ────────────────────────────────────────────────────
172
+
173
+ def graceful_shutdown!
174
+ warn '[IDE::Server] shutting down'
175
+ save_session!
176
+ end
177
+
178
+ def save_session!
179
+ # Delegate to Memory::SessionPersistence if available.
180
+ @session_persistence.save if defined?(RubynCode::Memory::SessionPersistence) && @session_persistence
181
+ rescue StandardError => e
182
+ warn "[IDE::Server] session save failed: #{e.message}"
183
+ end
184
+ end
185
+ end
186
+ end