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,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ module Adapters
8
+ # Wraps every tool invocation in IDE mode. Emits JSON-RPC notifications
9
+ # that the VS Code extension consumes, precomputes file edits so the
10
+ # editor can render a diff before any write touches disk, and gates
11
+ # mutating operations behind acceptance/approval from the IDE client.
12
+ #
13
+ # Gating policy depends on the permission mode:
14
+ # :default → approve every mutating tool + every file edit
15
+ # :accept_edits → auto-approve file edits, prompt for bash/other
16
+ # :plan_only → read-only, block all writes
17
+ # :auto → auto-approve everything except deny-listed
18
+ # :dont_ask → auto-deny all non-read-only tools
19
+ # :bypass → no checks (legacy yolo)
20
+ #
21
+ # In all modes the adapter emits notifications so the UI reflects
22
+ # what's happening.
23
+ class ToolOutput
24
+ # Wake up periodically to check for thread interrupts (cancel).
25
+ # No auto-deny — waits indefinitely until the user decides.
26
+ WAIT_POLL_INTERVAL = 5 # seconds
27
+
28
+ READ_ONLY_TOOLS = %w[
29
+ read_file glob grep
30
+ git_status git_diff git_log git_commit
31
+ memory_search web_fetch web_search
32
+ run_specs
33
+ ].freeze
34
+
35
+ FILE_WRITE_TOOLS = %w[write_file edit_file].freeze
36
+
37
+ VALID_PERMISSION_MODES = %i[default accept_edits plan_only auto dont_ask bypass].freeze
38
+
39
+ attr_accessor :permission_mode
40
+
41
+ def initialize(server, permission_mode: :default, yolo: false, hook_runner: nil)
42
+ @server = server
43
+ @permission_mode = yolo ? :bypass : permission_mode.to_sym
44
+ @hook_runner = hook_runner
45
+ @mutex = Mutex.new
46
+
47
+ # { request_id => { cv: ConditionVariable, approved: nil|true|false } }
48
+ @pending_approvals = {}
49
+
50
+ # { edit_id => { cv: ConditionVariable, accepted: nil|true|false } }
51
+ @pending_edits = {}
52
+ end
53
+
54
+ # Main entry point. Wraps a tool call, emitting IDE notifications
55
+ # and gating execution behind acceptance when required.
56
+ #
57
+ # adapter.wrap_execution("write_file", { path: "foo.rb", content: "..." }) do
58
+ # executor.execute("write_file", params)
59
+ # end
60
+ #
61
+ def wrap_execution(tool_name, args, &block)
62
+ request_id = generate_id
63
+ args = stringify_keys(args)
64
+
65
+ return execute_and_notify(request_id, tool_name, args, &block) if read_only?(tool_name)
66
+
67
+ # Non-read-only tools: gating depends on permission mode
68
+ case @permission_mode
69
+ when :bypass, :auto
70
+ # Auto-approve everything — run without waiting
71
+ execute_and_notify(request_id, tool_name, args, &block)
72
+ when :plan_only
73
+ emit_tool_use(request_id, tool_name, args, requires_approval: false)
74
+ msg = 'Plan mode: write operations blocked'
75
+ emit_tool_result(request_id, tool_name, msg, success: false, args: args)
76
+ raise RubynCode::UserDeniedError, msg
77
+ when :dont_ask
78
+ emit_tool_use(request_id, tool_name, args, requires_approval: false)
79
+ msg = 'Auto-denied: permission mode is dont_ask'
80
+ emit_tool_result(request_id, tool_name, msg, success: false, args: args)
81
+ raise RubynCode::UserDeniedError, msg
82
+ when :accept_edits
83
+ if file_write?(tool_name)
84
+ execute_with_edit_gate(request_id, tool_name, args, &block)
85
+ else
86
+ execute_with_approval(request_id, tool_name, args, &block)
87
+ end
88
+ else # :default
89
+ if file_write?(tool_name)
90
+ execute_with_edit_gate(request_id, tool_name, args, &block)
91
+ else
92
+ execute_with_approval(request_id, tool_name, args, &block)
93
+ end
94
+ end
95
+ end
96
+
97
+ # Called by ApproveToolUseHandler when the IDE client responds.
98
+ def resolve_approval(request_id, approved)
99
+ @mutex.synchronize do
100
+ pending = @pending_approvals[request_id]
101
+ return false unless pending
102
+
103
+ pending[:approved] = approved
104
+ pending[:cv].signal
105
+ true
106
+ end
107
+ end
108
+
109
+ # Called by AcceptEditHandler when the IDE client responds.
110
+ def resolve_edit(edit_id, accepted)
111
+ @mutex.synchronize do
112
+ pending = @pending_edits[edit_id]
113
+ return false unless pending
114
+
115
+ pending[:accepted] = accepted
116
+ pending[:cv].signal
117
+ true
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ # ── Read-only and streaming paths ────────────────────────────────
124
+
125
+ def execute_and_notify(request_id, tool_name, args)
126
+ emit_tool_use(request_id, tool_name, args, requires_approval: false)
127
+ result = yield
128
+ emit_tool_result(request_id, tool_name, result, success: true, args: args)
129
+ result
130
+ rescue StandardError => e
131
+ emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
132
+ raise
133
+ end
134
+
135
+ # ── File write tools (write_file, edit_file) ─────────────────────
136
+
137
+ def execute_with_edit_gate(request_id, tool_name, args, &)
138
+ emit_tool_use(request_id, tool_name, args, requires_approval: false)
139
+
140
+ preview = compute_preview(tool_name, args)
141
+ return emit_error(request_id, tool_name, preview[:error]) if preview[:error]
142
+
143
+ # Always emit the file/edit or file/create notification so the
144
+ # extension can surface the change — opens a diff editor in normal
145
+ # mode or flashes "Rubyn auto-applied…" and applies via workspace
146
+ # edit in yolo mode. Either way the user sees what changed.
147
+ accepted = notify_and_await_edit(tool_name, preview, args)
148
+ return deny_edit(request_id, tool_name, preview[:type]) unless accepted
149
+
150
+ apply_edit(request_id, tool_name, args, &)
151
+ end
152
+
153
+ def compute_preview(tool_name, args)
154
+ tool = build_tool(tool_name)
155
+ sym_args = symbolize_keys(args)
156
+ result = tool.preview_content(**sym_args)
157
+ { content: result[:content], type: result[:type] }
158
+ rescue StandardError => e
159
+ { error: e.message }
160
+ end
161
+
162
+ def build_tool(tool_name)
163
+ klass = Tools::Registry.get(tool_name)
164
+ klass.new(project_root: @server.workspace_path || Dir.pwd)
165
+ end
166
+
167
+ def notify_and_await_edit(tool_name, preview, args)
168
+ edit_id = generate_id
169
+ path = args['path']
170
+ method = preview[:type] == 'create' ? 'file/create' : 'file/edit'
171
+
172
+ params = { 'editId' => edit_id, 'path' => path, 'content' => preview[:content] }
173
+ params['type'] = preview[:type] if method == 'file/edit'
174
+
175
+ @server.notify(method, params)
176
+
177
+ # In accept_edits mode, auto-approve file writes without waiting
178
+ return true if @permission_mode == :accept_edits
179
+
180
+ fire_permission_request(tool_name, edit_id)
181
+ wait_for_edit(edit_id)
182
+ end
183
+
184
+ def apply_edit(request_id, tool_name, args)
185
+ result = yield
186
+ emit_tool_result(request_id, tool_name, result, success: true, args: args)
187
+ result
188
+ rescue StandardError => e
189
+ emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
190
+ raise
191
+ end
192
+
193
+ def deny_edit(request_id, tool_name, type)
194
+ summary = "User rejected this #{type}. Do not retry the same content."
195
+ emit_tool_result(request_id, tool_name, summary, success: false)
196
+ raise RubynCode::UserDeniedError, summary
197
+ end
198
+
199
+ def emit_error(request_id, tool_name, message)
200
+ summary = "Error: #{message}"
201
+ emit_tool_result(request_id, tool_name, summary, success: false)
202
+ summary
203
+ end
204
+
205
+ # ── Approval-gated tools (bash, etc.) ────────────────────────────
206
+
207
+ def execute_with_approval(request_id, tool_name, args)
208
+ emit_tool_use(request_id, tool_name, args, requires_approval: true)
209
+
210
+ fire_permission_request(tool_name, request_id)
211
+ approved = wait_for_approval(request_id)
212
+ unless approved
213
+ summary = 'User refused this tool invocation. Do not retry the same call.'
214
+ emit_tool_result(request_id, tool_name, summary, success: false, args: args)
215
+ raise RubynCode::UserDeniedError, summary
216
+ end
217
+
218
+ result = yield
219
+ emit_tool_result(request_id, tool_name, result, success: true, args: args)
220
+ result
221
+ rescue StandardError => e
222
+ emit_tool_result(request_id, tool_name, e.message, success: false, args: args)
223
+ raise
224
+ end
225
+
226
+ # ── Notifications ────────────────────────────────────────────────
227
+
228
+ def emit_tool_use(request_id, tool_name, args, requires_approval:)
229
+ @server.notify('tool/use', {
230
+ 'requestId' => request_id,
231
+ 'tool' => tool_name,
232
+ 'args' => args,
233
+ 'requiresApproval' => requires_approval
234
+ })
235
+ end
236
+
237
+ def emit_tool_result(request_id, tool_name, result, success:, args: {})
238
+ @server.notify('tool/result', {
239
+ 'requestId' => request_id,
240
+ 'tool' => tool_name,
241
+ 'success' => success,
242
+ 'summary' => build_summary(tool_name, result, success, args)
243
+ })
244
+ end
245
+
246
+ # Ask the tool class for its one-line summary ("Edited foo.rb (1
247
+ # replacement)", "grep pattern (12 lines)", etc.). Tools that don't
248
+ # override Base.summarize return "", and the UI renders a clean
249
+ # "Done". The full tool output always lives in the conversation —
250
+ # summary is display-only. On failure we include the error so the
251
+ # user can see what went wrong.
252
+ def build_summary(tool_name, result, success, args)
253
+ return result.to_s[0, 500] unless success
254
+
255
+ klass = tool_class(tool_name)
256
+ return '' unless klass
257
+
258
+ klass.summarize(result.to_s, args || {}).to_s[0, 500]
259
+ rescue StandardError
260
+ ''
261
+ end
262
+
263
+ def tool_class(tool_name)
264
+ Tools::Registry.get(tool_name)
265
+ rescue ToolNotFoundError
266
+ nil
267
+ end
268
+
269
+ # ── Blocking waits ───────────────────────────────────────────────
270
+
271
+ def wait_for_approval(request_id)
272
+ cv = ConditionVariable.new
273
+ @mutex.synchronize { @pending_approvals[request_id] = { cv: cv, approved: nil } }
274
+
275
+ @mutex.synchronize do
276
+ # Wait indefinitely — no timeout, no auto-deny. The user decides
277
+ # when they're ready. Poll periodically so thread interrupts
278
+ # (cancel) can break us out.
279
+ while @pending_approvals[request_id][:approved].nil?
280
+ cv.wait(@mutex, WAIT_POLL_INTERVAL)
281
+ end
282
+ @pending_approvals.delete(request_id)[:approved]
283
+ end
284
+ end
285
+
286
+ def wait_for_edit(edit_id)
287
+ cv = ConditionVariable.new
288
+ @mutex.synchronize { @pending_edits[edit_id] = { cv: cv, accepted: nil } }
289
+
290
+ @mutex.synchronize do
291
+ while @pending_edits[edit_id][:accepted].nil?
292
+ cv.wait(@mutex, WAIT_POLL_INTERVAL)
293
+ end
294
+ @pending_edits.delete(edit_id)[:accepted]
295
+ end
296
+ end
297
+
298
+ # ── Hook helpers ───────────────────────────────────────────────
299
+
300
+ def fire_permission_request(tool_name, request_id)
301
+ @hook_runner&.fire(:permission_request, tool_name: tool_name, request_id: request_id)
302
+ end
303
+
304
+ # ── Helpers ──────────────────────────────────────────────────────
305
+
306
+ def read_only?(tool_name)
307
+ READ_ONLY_TOOLS.include?(tool_name)
308
+ end
309
+
310
+ def file_write?(tool_name)
311
+ FILE_WRITE_TOOLS.include?(tool_name)
312
+ end
313
+
314
+ def stringify_keys(hash)
315
+ return {} unless hash.is_a?(Hash)
316
+
317
+ hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
318
+ end
319
+
320
+ def symbolize_keys(hash)
321
+ hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
322
+ end
323
+
324
+ def generate_id
325
+ "#{Time.now.to_i}-#{SecureRandom.hex(4)}"
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module RubynCode
6
+ module IDE
7
+ # Sends JSON-RPC requests from the CLI server to the VS Code extension
8
+ # and awaits responses. Enables the CLI to ask the IDE to do things like
9
+ # open diffs, read diagnostics, or navigate to a file.
10
+ #
11
+ # Uses the server's write mutex for thread-safe output. Tracks pending
12
+ # responses via a { id => ConditionVariable } map.
13
+ class Client
14
+ DEFAULT_TIMEOUT = 30 # seconds
15
+
16
+ def initialize(server)
17
+ @server = server
18
+ @mutex = Mutex.new
19
+ @next_id = 1000 # Start high to avoid collisions with client IDs
20
+ @pending = {} # { id => { cv: ConditionVariable, result: nil, error: nil } }
21
+ end
22
+
23
+ # Send a JSON-RPC request to the extension and block until the response
24
+ # arrives or the timeout expires.
25
+ #
26
+ # @param method [String] the RPC method name (e.g. "ide/readSelection")
27
+ # @param params [Hash] the request params
28
+ # @param timeout [Numeric] seconds to wait for a response
29
+ # @return [Hash] the result from the extension
30
+ # @raise [TimeoutError] if no response within timeout
31
+ # @raise [StandardError] if the extension returns an RPC error
32
+ def request(method, params = {}, timeout: DEFAULT_TIMEOUT)
33
+ id = allocate_id
34
+ cv = ConditionVariable.new
35
+
36
+ @mutex.synchronize do
37
+ @pending[id] = { cv: cv, result: nil, error: nil }
38
+ end
39
+
40
+ # Write the request via the server's write path
41
+ write_raw({
42
+ 'jsonrpc' => Protocol::JSONRPC_VERSION,
43
+ 'id' => id,
44
+ 'method' => method,
45
+ 'params' => Protocol.send(:stringify_keys_deep, params)
46
+ })
47
+
48
+ # Block until the extension responds or we time out
49
+ @mutex.synchronize do
50
+ deadline = Time.now + timeout
51
+ while @pending[id][:result].nil? && @pending[id][:error].nil?
52
+ remaining = deadline - Time.now
53
+ if remaining <= 0
54
+ @pending.delete(id)
55
+ raise TimeoutError, "IDE RPC request '#{method}' timed out after #{timeout}s"
56
+ end
57
+ cv.wait(@mutex, remaining)
58
+ end
59
+
60
+ entry = @pending.delete(id)
61
+ raise StandardError, entry[:error] if entry[:error]
62
+
63
+ entry[:result]
64
+ end
65
+ end
66
+
67
+ # Called by the server when it receives a response message (has id + result/error,
68
+ # no method) that matches one of our pending outbound requests.
69
+ #
70
+ # @param id [Integer] the response id
71
+ # @param result [Hash, nil] the result payload
72
+ # @param error [Hash, nil] the error payload
73
+ def resolve(id, result: nil, error: nil)
74
+ @mutex.synchronize do
75
+ entry = @pending[id]
76
+ return unless entry
77
+
78
+ if error
79
+ entry[:error] = "RPC error #{error['code']}: #{error['message']}"
80
+ else
81
+ entry[:result] = result || {}
82
+ end
83
+ entry[:cv].signal
84
+ end
85
+ end
86
+
87
+ # Check if we have a pending request with this id.
88
+ def pending?(id)
89
+ @mutex.synchronize { @pending.key?(id) }
90
+ end
91
+
92
+ private
93
+
94
+ def allocate_id
95
+ @mutex.synchronize do
96
+ id = @next_id
97
+ @next_id += 1
98
+ id
99
+ end
100
+ end
101
+
102
+ # Write using the server's write method for thread-safe, testable output.
103
+ def write_raw(msg_hash)
104
+ @server.send(:write, msg_hash)
105
+ end
106
+
107
+ class TimeoutError < StandardError; end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "acceptEdit" JSON-RPC request.
7
+ #
8
+ # The extension sends this after the user accepts or rejects a proposed
9
+ # file edit surfaced via a file/edit or file/create notification. All
10
+ # pending-edit state lives in the per-session ToolOutput adapter; this
11
+ # handler is a thin delegate so the server has something to register at
12
+ # the method name.
13
+ class AcceptEditHandler
14
+ def initialize(server)
15
+ @server = server
16
+ end
17
+
18
+ def call(params)
19
+ edit_id = params['editId']
20
+ accepted = params['accepted']
21
+
22
+ return { 'applied' => false, 'error' => 'Missing editId' } unless edit_id
23
+
24
+ adapter = @server.tool_output_adapter
25
+ return { 'applied' => false, 'error' => 'No active session' } unless adapter
26
+
27
+ resolved = adapter.resolve_edit(edit_id, accepted ? true : false)
28
+ return { 'applied' => false, 'error' => "No pending edit: #{edit_id}" } unless resolved
29
+
30
+ { 'applied' => accepted ? true : false }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "approveToolUse" JSON-RPC request.
7
+ #
8
+ # The extension sends this after the user approves or denies a tool
9
+ # invocation surfaced with requiresApproval=true. All pending-approval
10
+ # state lives in the per-session ToolOutput adapter; this handler is a
11
+ # thin delegate so the server has something registered at the method name.
12
+ class ApproveToolUseHandler
13
+ def initialize(server)
14
+ @server = server
15
+ end
16
+
17
+ def call(params)
18
+ request_id = params['requestId']
19
+ approved = params['approved']
20
+
21
+ return { 'resolved' => false, 'error' => 'Missing requestId' } unless request_id
22
+
23
+ adapter = @server.tool_output_adapter
24
+ return { 'resolved' => false, 'error' => 'No active session' } unless adapter
25
+
26
+ resolved = adapter.resolve_approval(request_id, approved ? true : false)
27
+ return { 'resolved' => false, 'error' => "No pending request: #{request_id}" } unless resolved
28
+
29
+ { 'resolved' => true, 'requestId' => request_id }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles the "cancel" JSON-RPC request.
7
+ #
8
+ # Signals the agent loop thread for the given session to stop,
9
+ # then returns confirmation.
10
+ class CancelHandler
11
+ def initialize(server)
12
+ @server = server
13
+ end
14
+
15
+ def call(params)
16
+ session_id = params['sessionId']
17
+
18
+ return { 'cancelled' => false, 'error' => 'Missing sessionId' } unless session_id
19
+
20
+ # Delegate to the PromptHandler which owns the session threads
21
+ prompt_handler = @server.handler_instance(:prompt)
22
+ prompt_handler&.cancel_session(session_id)
23
+
24
+ # Fire the stop hook so extensions can react to session cancellation
25
+ fire_stop_hook(session_id)
26
+
27
+ { 'cancelled' => true, 'sessionId' => session_id }
28
+ end
29
+
30
+ private
31
+
32
+ def fire_stop_hook(session_id)
33
+ hook_registry = Hooks::Registry.new
34
+ hook_runner = Hooks::Runner.new(registry: hook_registry)
35
+ Hooks::BuiltIn.register_all!(hook_registry)
36
+ hook_runner.fire(:stop, session_id: session_id)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module IDE
5
+ module Handlers
6
+ # Handles "config/get" JSON-RPC requests from the IDE extension.
7
+ #
8
+ # When a key is provided, returns that single setting with its source.
9
+ # When no key is provided, returns all configurable settings plus
10
+ # provider definitions so the extension can populate its UI.
11
+ class ConfigGetHandler
12
+ EXPOSED_KEYS = %w[
13
+ provider model model_mode max_iterations max_sub_agent_iterations max_output_chars
14
+ context_threshold_tokens session_budget_usd daily_budget_usd permission_mode
15
+ ].freeze
16
+
17
+ def initialize(server)
18
+ @server = server
19
+ end
20
+
21
+ def call(params)
22
+ settings = Config::Settings.new
23
+ key = params['key']
24
+
25
+ if key
26
+ single_key_response(settings, key)
27
+ else
28
+ all_settings_response(settings)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def single_key_response(settings, key)
35
+ unless EXPOSED_KEYS.include?(key.to_s)
36
+ return { 'key' => key, 'value' => nil, 'source' => 'unknown',
37
+ 'error' => "Unknown config key: #{key}" }
38
+ end
39
+
40
+ value = settings.get(key)
41
+ source = settings.data.key?(key.to_s) ? 'config_file' : 'default'
42
+
43
+ { 'key' => key, 'value' => value, 'source' => source }
44
+ end
45
+
46
+ def all_settings_response(settings)
47
+ result = {}
48
+
49
+ EXPOSED_KEYS.each do |key|
50
+ sym = key.to_sym
51
+ value = settings.get(key)
52
+ default = Config::Settings::DEFAULT_MAP[sym]
53
+ result[key] = { 'value' => value, 'default' => default }
54
+ end
55
+
56
+ providers = settings.data['providers'] || {}
57
+
58
+ { 'settings' => result, 'providers' => providers }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end