rubyn-code 0.3.0 → 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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -19
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +32 -3
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +9 -1
  7. data/lib/rubyn_code/agent/loop.rb +7 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
  9. data/lib/rubyn_code/agent/tool_processor.rb +21 -1
  10. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  11. data/lib/rubyn_code/auth/token_store.rb +50 -9
  12. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  13. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  14. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  15. data/lib/rubyn_code/cli/app.rb +32 -1
  16. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  17. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  18. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  19. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  20. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  21. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  22. data/lib/rubyn_code/cli/first_run.rb +159 -0
  23. data/lib/rubyn_code/cli/repl.rb +6 -1
  24. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  25. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  26. data/lib/rubyn_code/cli/repl_setup.rb +36 -0
  27. data/lib/rubyn_code/config/defaults.rb +1 -0
  28. data/lib/rubyn_code/config/schema.json +49 -0
  29. data/lib/rubyn_code/config/settings.rb +7 -4
  30. data/lib/rubyn_code/config/validator.rb +63 -0
  31. data/lib/rubyn_code/context/context_budget.rb +16 -1
  32. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  33. data/lib/rubyn_code/context/manager.rb +37 -3
  34. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  35. data/lib/rubyn_code/hooks/registry.rb +4 -0
  36. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  37. data/lib/rubyn_code/ide/client.rb +110 -0
  38. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  39. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  40. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  41. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  42. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  43. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  44. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  45. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  46. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  47. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  48. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  49. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  50. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  51. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  52. data/lib/rubyn_code/ide/handlers.rb +76 -0
  53. data/lib/rubyn_code/ide/protocol.rb +111 -0
  54. data/lib/rubyn_code/ide/server.rb +186 -0
  55. data/lib/rubyn_code/index/codebase_index.rb +67 -1
  56. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  57. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  58. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  59. data/lib/rubyn_code/llm/client.rb +29 -4
  60. data/lib/rubyn_code/mcp/config.rb +2 -1
  61. data/lib/rubyn_code/memory/search.rb +1 -0
  62. data/lib/rubyn_code/self_test.rb +315 -0
  63. data/lib/rubyn_code/skills/catalog.rb +66 -0
  64. data/lib/rubyn_code/skills/loader.rb +43 -0
  65. data/lib/rubyn_code/tasks/models.rb +1 -0
  66. data/lib/rubyn_code/tools/base.rb +13 -0
  67. data/lib/rubyn_code/tools/bash.rb +5 -0
  68. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  69. data/lib/rubyn_code/tools/executor.rb +61 -6
  70. data/lib/rubyn_code/tools/glob.rb +6 -0
  71. data/lib/rubyn_code/tools/grep.rb +6 -0
  72. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  73. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  74. data/lib/rubyn_code/tools/output_compressor.rb +6 -1
  75. data/lib/rubyn_code/tools/read_file.rb +6 -0
  76. data/lib/rubyn_code/tools/registry.rb +11 -0
  77. data/lib/rubyn_code/tools/write_file.rb +17 -0
  78. data/lib/rubyn_code/version.rb +1 -1
  79. data/lib/rubyn_code.rb +22 -0
  80. data/skills/rubyn_self_test.md +13 -1
  81. metadata +31 -1
@@ -25,8 +25,9 @@ module RubynCode
25
25
 
26
26
  attr_reader :loaded_files, :signature_files, :tokens_used
27
27
 
28
- def initialize(budget: DEFAULT_BUDGET)
28
+ def initialize(budget: DEFAULT_BUDGET, codebase_index: nil)
29
29
  @budget = budget
30
+ @codebase_index = codebase_index
30
31
  @loaded_files = []
31
32
  @signature_files = []
32
33
  @tokens_used = 0
@@ -34,6 +35,10 @@ module RubynCode
34
35
 
35
36
  # Load context for a primary file, filling budget with related files.
36
37
  # Returns array of { file:, content:, mode: :full|:signatures }
38
+ #
39
+ # When a codebase_index is available and no related_files are supplied,
40
+ # uses impact_analysis to auto-discover related files (specs,
41
+ # associated models, controllers, etc.).
37
42
  def load_for(file_path, related_files: [])
38
43
  results = []
39
44
 
@@ -46,6 +51,9 @@ module RubynCode
46
51
  @loaded_files << file_path
47
52
  results << { file: file_path, content: primary_content, mode: :full }
48
53
 
54
+ # Auto-discover related files from the index when none supplied
55
+ related_files = discover_related_files(file_path) if related_files.empty? && @codebase_index
56
+
49
57
  # Sort related files by priority and fill remaining budget
50
58
  sorted = prioritize(related_files)
51
59
  remaining = @budget - @tokens_used
@@ -81,6 +89,13 @@ module RubynCode
81
89
 
82
90
  private
83
91
 
92
+ def discover_related_files(file_path)
93
+ analysis = @codebase_index.impact_analysis(file_path)
94
+ analysis[:affected_files].reject { |f| f == file_path }
95
+ rescue StandardError
96
+ []
97
+ end
98
+
84
99
  def load_full_files(sorted, results, remaining)
85
100
  sorted.each do |rel_path|
86
101
  content = safe_read(rel_path)
@@ -23,13 +23,16 @@ module RubynCode
23
23
  def self.call(messages, threshold:, keep_recent: 6)
24
24
  return nil if messages.size <= keep_recent + 2
25
25
 
26
- # Keep first message + last N messages, snip the middle
27
- first = messages.first
26
+ # Always preserve the very first message (may contain critical
27
+ # system-level context like auth shims) AND the first real user
28
+ # message so the agent retains the user's original request.
29
+ anchors = build_anchors(messages)
30
+
28
31
  recent = messages.last(keep_recent)
29
- snipped_count = messages.size - keep_recent - 1
32
+ snipped_count = messages.size - keep_recent - anchors.size
30
33
 
31
34
  collapsed = [
32
- first,
35
+ *anchors,
33
36
  { role: 'user', content: format(SNIP_MARKER, snipped_count) },
34
37
  *recent
35
38
  ]
@@ -40,6 +43,33 @@ module RubynCode
40
43
  rescue JSON::GeneratorError
41
44
  nil
42
45
  end
46
+
47
+ # Builds the list of anchor messages to preserve at the top.
48
+ # Always keeps messages[0] (may contain critical system context).
49
+ # If messages[0] is a system injection, also keeps the first real
50
+ # user message so the agent retains the original request.
51
+ def self.build_anchors(messages)
52
+ first = messages.first
53
+ anchors = [first]
54
+ return anchors unless system_injection?(first)
55
+
56
+ user_msg = first_real_user_message(messages)
57
+ anchors << user_msg if user_msg
58
+ anchors
59
+ end
60
+
61
+ def self.system_injection?(msg)
62
+ content = msg[:content]
63
+ content.is_a?(String) && content.start_with?('[system]')
64
+ end
65
+
66
+ def self.first_real_user_message(messages)
67
+ messages[1..].find do |msg|
68
+ msg[:role] == 'user' && !system_injection?(msg)
69
+ end
70
+ end
71
+
72
+ private_class_method :build_anchors
43
73
  end
44
74
  end
45
75
  end
@@ -10,7 +10,7 @@ module RubynCode
10
10
  class Manager
11
11
  CHARS_PER_TOKEN = 4
12
12
 
13
- attr_reader :total_input_tokens, :total_output_tokens
13
+ attr_reader :total_input_tokens, :total_output_tokens, :current_turn
14
14
 
15
15
  # @param threshold [Integer] estimated token count that triggers auto-compaction
16
16
  # @param llm_client [LLM::Client, nil] needed for LLM-driven compaction
@@ -19,10 +19,18 @@ module RubynCode
19
19
  @llm_client = llm_client
20
20
  @total_input_tokens = 0
21
21
  @total_output_tokens = 0
22
+ @last_compaction_turn = -1
23
+ @current_turn = 0
22
24
  end
23
25
 
24
26
  attr_writer :llm_client
25
27
 
28
+ # Advances the turn counter. Call once per iteration so that
29
+ # duplicate compaction calls within the same turn are skipped.
30
+ def advance_turn!
31
+ @current_turn += 1
32
+ end
33
+
26
34
  # Accumulates token counts from an LLM response usage object.
27
35
  #
28
36
  # @param usage [LLM::Usage, #input_tokens] usage data from an LLM response
@@ -60,16 +68,24 @@ module RubynCode
60
68
  # Fraction of the compaction threshold at which micro-compact kicks in.
61
69
  # Running it too early busts the prompt cache prefix (mutated messages
62
70
  # change the hash, invalidating server-side cached tokens).
63
- MICRO_COMPACT_RATIO = 0.7
71
+ # Anthropic has prompt caching so we delay compaction (0.7).
72
+ # OpenAI has no cache prefix to protect so we compact earlier (0.5).
73
+ MICRO_COMPACT_RATIO_CACHED = 0.7
74
+ MICRO_COMPACT_RATIO_UNCACHED = 0.5
64
75
 
65
76
  def check_compaction!(conversation)
77
+ # Guard: skip if compaction already ran this turn
78
+ return if @last_compaction_turn == @current_turn
79
+
80
+ @last_compaction_turn = @current_turn
81
+
66
82
  messages = conversation.messages
67
83
 
68
84
  # Step 1: Zero-cost micro-compact — but only when we're approaching
69
85
  # the compaction threshold. Running it every turn mutates old messages,
70
86
  # which invalidates the prompt cache prefix and wastes tokens.
71
87
  est = estimated_tokens(messages)
72
- MicroCompact.call(messages) if est > (@threshold * MICRO_COMPACT_RATIO)
88
+ MicroCompact.call(messages) if est > (@threshold * micro_compact_ratio)
73
89
 
74
90
  return unless needs_compaction?(messages)
75
91
 
@@ -94,10 +110,28 @@ module RubynCode
94
110
  def reset!
95
111
  @total_input_tokens = 0
96
112
  @total_output_tokens = 0
113
+ @last_compaction_turn = -1
114
+ @current_turn = 0
97
115
  end
98
116
 
99
117
  private
100
118
 
119
+ # Returns the micro-compact ratio based on the active provider.
120
+ # Providers with prompt caching (Anthropic) use a higher ratio to
121
+ # preserve cached prefixes; providers without caching compact earlier.
122
+ def micro_compact_ratio
123
+ return MICRO_COMPACT_RATIO_UNCACHED if uncached_provider?
124
+
125
+ MICRO_COMPACT_RATIO_CACHED
126
+ end
127
+
128
+ def uncached_provider?
129
+ return false unless @llm_client
130
+
131
+ provider = @llm_client.provider_name if @llm_client.respond_to?(:provider_name)
132
+ %w[openai openai_compatible].include?(provider)
133
+ end
134
+
101
135
  def apply_compacted_messages(conversation, new_messages)
102
136
  if conversation.respond_to?(:replace_messages)
103
137
  conversation.replace_messages(new_messages)
@@ -68,7 +68,7 @@ module RubynCode
68
68
  ]
69
69
 
70
70
  options = {}
71
- options[:model] = 'claude-sonnet-4-20250514' if llm_client.respond_to?(:chat)
71
+ options[:model] = 'claude-sonnet-4-6' if llm_client.respond_to?(:chat)
72
72
 
73
73
  response = llm_client.chat(messages: summary_messages, **options)
74
74
 
@@ -17,6 +17,10 @@ module RubynCode
17
17
  on_stall
18
18
  on_error
19
19
  on_session_end
20
+ session_start
21
+ user_prompt_submit
22
+ permission_request
23
+ stop
20
24
  ].freeze
21
25
 
22
26
  Hook = Data.define(:callable, :priority)
@@ -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