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.
- checksums.yaml +4 -4
- data/README.md +77 -19
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +32 -3
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
- data/lib/rubyn_code/agent/llm_caller.rb +9 -1
- data/lib/rubyn_code/agent/loop.rb +7 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
- data/lib/rubyn_code/agent/tool_processor.rb +21 -1
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/token_store.rb +50 -9
- data/lib/rubyn_code/autonomous/daemon.rb +117 -14
- data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
- data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
- data/lib/rubyn_code/cli/app.rb +32 -1
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +32 -2
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/repl.rb +6 -1
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +36 -0
- data/lib/rubyn_code/config/defaults.rb +1 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +7 -4
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +16 -1
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/manager.rb +37 -3
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +67 -1
- data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
- data/lib/rubyn_code/llm/client.rb +29 -4
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/base.rb +13 -0
- data/lib/rubyn_code/tools/bash.rb +5 -0
- data/lib/rubyn_code/tools/edit_file.rb +62 -5
- data/lib/rubyn_code/tools/executor.rb +61 -6
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +6 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/output_compressor.rb +6 -1
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +22 -0
- data/skills/rubyn_self_test.md +13 -1
- metadata +31 -1
|
@@ -7,7 +7,7 @@ require 'time'
|
|
|
7
7
|
|
|
8
8
|
module RubynCode
|
|
9
9
|
module Auth
|
|
10
|
-
module TokenStore
|
|
10
|
+
module TokenStore # rubocop:disable Metrics/ModuleLength -- single-responsibility credential store
|
|
11
11
|
EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
|
|
12
12
|
KEYCHAIN_SERVICE = 'Claude Code-credentials'
|
|
13
13
|
|
|
@@ -21,25 +21,44 @@ module RubynCode
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Load API key for a given provider. Anthropic uses the full fallback chain.
|
|
24
|
+
# Other providers: stored key → env var.
|
|
24
25
|
def load_for_provider(provider)
|
|
25
26
|
return load if provider == 'anthropic'
|
|
26
27
|
|
|
28
|
+
stored = load_provider_key(provider)
|
|
29
|
+
return { access_token: stored, type: :api_key, source: :stored } if stored
|
|
30
|
+
|
|
27
31
|
env_key = resolve_env_key(provider)
|
|
28
32
|
api_key = ENV.fetch(env_key, nil)
|
|
29
33
|
api_key&.empty? == false ? { access_token: api_key, type: :api_key, source: :env } : nil
|
|
30
34
|
end
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
# Store an API key for a provider in tokens.yml (encrypted at rest).
|
|
37
|
+
def save_provider_key(provider, key)
|
|
33
38
|
ensure_directory!
|
|
39
|
+
data = load_tokens_file || {}
|
|
40
|
+
data['provider_keys'] ||= {}
|
|
41
|
+
data['provider_keys'][provider.to_s] = KeyEncryption.encrypt(key)
|
|
42
|
+
write_tokens_file(data)
|
|
43
|
+
end
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
# Retrieve a stored API key for a provider (decrypted transparently).
|
|
46
|
+
def load_provider_key(provider)
|
|
47
|
+
data = load_tokens_file
|
|
48
|
+
value = data&.dig('provider_keys', provider.to_s)
|
|
49
|
+
return nil unless value
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
migrate_plaintext_key!(data, provider, value) unless KeyEncryption.encrypted?(value)
|
|
52
|
+
KeyEncryption.decrypt(value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def save(access_token:, refresh_token:, expires_at:)
|
|
56
|
+
ensure_directory!
|
|
57
|
+
data = load_tokens_file || {}
|
|
58
|
+
data['access_token'] = access_token
|
|
59
|
+
data['refresh_token'] = refresh_token
|
|
60
|
+
data['expires_at'] = expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
|
|
61
|
+
write_tokens_file(data)
|
|
43
62
|
data
|
|
44
63
|
end
|
|
45
64
|
|
|
@@ -118,6 +137,28 @@ module RubynCode
|
|
|
118
137
|
{ access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
|
|
119
138
|
end
|
|
120
139
|
|
|
140
|
+
def write_tokens_file(data)
|
|
141
|
+
File.write(tokens_path, YAML.dump(data))
|
|
142
|
+
File.chmod(0o600, tokens_path)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Auto-encrypt a plaintext key from a pre-encryption install.
|
|
146
|
+
def migrate_plaintext_key!(data, provider, plaintext)
|
|
147
|
+
data['provider_keys'][provider.to_s] = KeyEncryption.encrypt(plaintext)
|
|
148
|
+
write_tokens_file(data)
|
|
149
|
+
rescue StandardError
|
|
150
|
+
nil # don't break reads if migration fails
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def load_tokens_file
|
|
154
|
+
return nil unless File.exist?(tokens_path)
|
|
155
|
+
|
|
156
|
+
data = YAML.safe_load_file(tokens_path, permitted_classes: [Time])
|
|
157
|
+
data.is_a?(Hash) ? data : nil
|
|
158
|
+
rescue Psych::SyntaxError, Errno::EACCES
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
121
162
|
def tokens_path = Config::Defaults::TOKENS_FILE
|
|
122
163
|
|
|
123
164
|
def ensure_directory!
|
|
@@ -14,8 +14,9 @@ module RubynCode
|
|
|
14
14
|
#
|
|
15
15
|
# Unlike the REPL, the daemon runs a full Agent::Loop per task — meaning
|
|
16
16
|
# it can read files, write code, run specs, and use every tool available.
|
|
17
|
-
class Daemon
|
|
17
|
+
class Daemon # rubocop:disable Metrics/ClassLength -- daemon lifecycle + retry + audit + cost
|
|
18
18
|
LIFECYCLE_STATES = %i[spawned working idle shutting_down stopped].freeze
|
|
19
|
+
MAX_TASK_RETRIES = 3
|
|
19
20
|
|
|
20
21
|
attr_reader :agent_name, :role, :state, :runs_completed, :total_cost
|
|
21
22
|
|
|
@@ -32,14 +33,17 @@ module RubynCode
|
|
|
32
33
|
# @param on_state_change [Proc, nil] callback invoked with (old_state, new_state)
|
|
33
34
|
# @param on_task_complete [Proc, nil] callback invoked with (task, result_text)
|
|
34
35
|
# @param on_task_error [Proc, nil] callback invoked with (task, error)
|
|
36
|
+
# @param session_persistence [Memory::SessionPersistence, nil] optional audit trail persistence
|
|
35
37
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
36
38
|
agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:,
|
|
37
39
|
max_runs: 100, max_cost: 10.0, poll_interval: 5, idle_timeout: 60,
|
|
38
|
-
on_state_change: nil, on_task_complete: nil, on_task_error: nil
|
|
40
|
+
on_state_change: nil, on_task_complete: nil, on_task_error: nil,
|
|
41
|
+
session_persistence: nil
|
|
39
42
|
)
|
|
40
43
|
assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:)
|
|
41
44
|
assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
|
|
42
45
|
assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
|
|
46
|
+
@session_persistence = session_persistence
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
# Enters the work-idle-work cycle. Blocks the calling thread until
|
|
@@ -153,16 +157,18 @@ module RubynCode
|
|
|
153
157
|
agent_loop = build_agent_loop
|
|
154
158
|
result_text = agent_loop.send_message(build_work_prompt(task))
|
|
155
159
|
|
|
156
|
-
# Accumulate cost
|
|
157
|
-
|
|
160
|
+
# Accumulate cost via CostCalculator using actual token counts
|
|
161
|
+
track_cost_from_context_manager(agent_loop)
|
|
158
162
|
|
|
159
163
|
# Mark the task as completed with the agent's result.
|
|
160
164
|
@task_manager.complete(task.id, result: result_text)
|
|
165
|
+
|
|
166
|
+
# Persist conversation as an audit trail
|
|
167
|
+
persist_session_audit(task, agent_loop)
|
|
168
|
+
|
|
161
169
|
@on_task_complete&.call(task, result_text)
|
|
162
170
|
rescue StandardError => e
|
|
163
|
-
|
|
164
|
-
@task_manager.update(task.id, status: 'pending', owner: nil, result: "Error: #{e.message}")
|
|
165
|
-
@on_task_error&.call(task, e)
|
|
171
|
+
handle_task_error(task, e)
|
|
166
172
|
end
|
|
167
173
|
|
|
168
174
|
# Builds a fresh Agent::Loop wired with all the real tools.
|
|
@@ -192,19 +198,116 @@ module RubynCode
|
|
|
192
198
|
)
|
|
193
199
|
end
|
|
194
200
|
|
|
195
|
-
#
|
|
201
|
+
# Computes USD cost from the context manager's token counts using
|
|
202
|
+
# Observability::CostCalculator. The old approach checked for a
|
|
203
|
+
# `total_cost` method that never existed on Context::Manager, so
|
|
204
|
+
# @total_cost was always 0.0 and the max_cost safety limit never fired.
|
|
196
205
|
#
|
|
197
206
|
# @param agent_loop [Agent::Loop]
|
|
198
207
|
# @return [void]
|
|
199
|
-
def
|
|
200
|
-
# The context manager tracks token usage; we extract cost if available.
|
|
201
|
-
# This is best-effort — the daemon's own total_cost is an approximation.
|
|
208
|
+
def track_cost_from_context_manager(agent_loop)
|
|
202
209
|
cm = agent_loop.instance_variable_get(:@context_manager)
|
|
203
|
-
return unless cm
|
|
210
|
+
return unless cm
|
|
211
|
+
|
|
212
|
+
tokens = extract_token_counts(cm)
|
|
213
|
+
return if tokens.values.all?(&:zero?)
|
|
214
|
+
|
|
215
|
+
model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-4-6'
|
|
216
|
+
@total_cost += Observability::CostCalculator.calculate(model: model, **tokens)
|
|
217
|
+
rescue StandardError
|
|
218
|
+
# Non-critical — cost tracking is best-effort
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @param context_mgr [Context::Manager]
|
|
222
|
+
# @return [Hash] :input_tokens, :output_tokens
|
|
223
|
+
def extract_token_counts(context_mgr)
|
|
224
|
+
{
|
|
225
|
+
input_tokens: context_mgr.respond_to?(:total_input_tokens) ? context_mgr.total_input_tokens.to_i : 0,
|
|
226
|
+
output_tokens: context_mgr.respond_to?(:total_output_tokens) ? context_mgr.total_output_tokens.to_i : 0
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Handles a task error with retry backoff. Increments the retry count
|
|
231
|
+
# in the task's metadata. After MAX_TASK_RETRIES, marks the task as
|
|
232
|
+
# failed instead of releasing it back to pending.
|
|
233
|
+
#
|
|
234
|
+
# @param task [Tasks::Task]
|
|
235
|
+
# @param error [StandardError]
|
|
236
|
+
# @return [void]
|
|
237
|
+
def handle_task_error(task, error)
|
|
238
|
+
retry_count = extract_retry_count(task) + 1
|
|
239
|
+
|
|
240
|
+
metadata = build_retry_metadata(task, retry_count)
|
|
241
|
+
if retry_count >= MAX_TASK_RETRIES
|
|
242
|
+
@task_manager.update(
|
|
243
|
+
task.id,
|
|
244
|
+
status: 'failed',
|
|
245
|
+
owner: nil,
|
|
246
|
+
result: "Failed after #{retry_count} retries. Last error: #{error.message}",
|
|
247
|
+
metadata: JSON.generate(metadata)
|
|
248
|
+
)
|
|
249
|
+
else
|
|
250
|
+
@task_manager.update(
|
|
251
|
+
task.id,
|
|
252
|
+
status: 'pending',
|
|
253
|
+
owner: nil,
|
|
254
|
+
result: "Error (retry #{retry_count}/#{MAX_TASK_RETRIES}): #{error.message}",
|
|
255
|
+
metadata: JSON.generate(metadata)
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
@on_task_error&.call(task, error)
|
|
259
|
+
end
|
|
204
260
|
|
|
205
|
-
|
|
261
|
+
# @param task [Tasks::Task]
|
|
262
|
+
# @return [Integer]
|
|
263
|
+
def extract_retry_count(task)
|
|
264
|
+
meta = parse_task_metadata(task)
|
|
265
|
+
(meta[:retry_count] || meta['retry_count'] || 0).to_i
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @param task [Tasks::Task]
|
|
269
|
+
# @param retry_count [Integer]
|
|
270
|
+
# @return [Hash]
|
|
271
|
+
def build_retry_metadata(task, retry_count)
|
|
272
|
+
meta = parse_task_metadata(task)
|
|
273
|
+
meta.merge(retry_count: retry_count)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @param task [Tasks::Task]
|
|
277
|
+
# @return [Hash]
|
|
278
|
+
def parse_task_metadata(task)
|
|
279
|
+
raw = task.metadata
|
|
280
|
+
case raw
|
|
281
|
+
when Hash then raw
|
|
282
|
+
when String then JSON.parse(raw, symbolize_names: true)
|
|
283
|
+
else {}
|
|
284
|
+
end
|
|
285
|
+
rescue JSON::ParserError
|
|
286
|
+
{}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Persists the agent's conversation as a session audit trail after
|
|
290
|
+
# completing a task, so there's a record of what the daemon did.
|
|
291
|
+
#
|
|
292
|
+
# @param task [Tasks::Task]
|
|
293
|
+
# @param agent_loop [Agent::Loop]
|
|
294
|
+
# @return [void]
|
|
295
|
+
def persist_session_audit(task, agent_loop)
|
|
296
|
+
return unless @session_persistence
|
|
297
|
+
|
|
298
|
+
conversation = agent_loop.instance_variable_get(:@conversation)
|
|
299
|
+
return unless conversation.respond_to?(:messages)
|
|
300
|
+
|
|
301
|
+
session_id = "daemon-#{@agent_name}-#{task.id}"
|
|
302
|
+
@session_persistence.save_session(
|
|
303
|
+
session_id: session_id,
|
|
304
|
+
project_path: @project_root,
|
|
305
|
+
messages: conversation.messages,
|
|
306
|
+
title: "Daemon: #{task.title}",
|
|
307
|
+
metadata: { agent_name: @agent_name, task_id: task.id, task_title: task.title }
|
|
308
|
+
)
|
|
206
309
|
rescue StandardError
|
|
207
|
-
# Non-critical
|
|
310
|
+
# Non-critical — audit persistence is best-effort
|
|
208
311
|
end
|
|
209
312
|
|
|
210
313
|
# ── Idle phase ───────────────────────────────────────────────
|
|
@@ -53,26 +53,6 @@ module RubynCode
|
|
|
53
53
|
@interrupted = true
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
# Re-injects the agent's identity message when the conversation
|
|
57
|
-
# context has been compressed (i.e. the messages array is very short).
|
|
58
|
-
# This ensures the agent still knows who it is after compaction.
|
|
59
|
-
#
|
|
60
|
-
# @param messages [Array<Hash>] the current conversation messages
|
|
61
|
-
# @param identity [String] the identity/system prompt to re-inject
|
|
62
|
-
# @param threshold [Integer] message count below which re-injection triggers (default 3)
|
|
63
|
-
# @return [void]
|
|
64
|
-
def self.reinject_identity(messages, identity:, threshold: 3)
|
|
65
|
-
return if messages.length >= threshold
|
|
66
|
-
return if identity.nil? || identity.empty?
|
|
67
|
-
|
|
68
|
-
# Only re-inject if the identity is not already present as the
|
|
69
|
-
# first user message.
|
|
70
|
-
first_user = messages.find { |m| m[:role] == 'user' }
|
|
71
|
-
return if first_user && first_user[:content].to_s.include?(identity[0, 100])
|
|
72
|
-
|
|
73
|
-
messages.unshift({ role: 'user', content: identity })
|
|
74
|
-
end
|
|
75
|
-
|
|
76
56
|
private
|
|
77
57
|
|
|
78
58
|
# @return [Boolean]
|
|
@@ -6,16 +6,19 @@ module RubynCode
|
|
|
6
6
|
# Uses optimistic locking to handle race conditions when multiple
|
|
7
7
|
# agents attempt to claim the same task concurrently.
|
|
8
8
|
module TaskClaimer
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
#
|
|
9
|
+
MAX_RETRIES = 3
|
|
10
|
+
|
|
11
|
+
# Finds the first ready (pending, unowned) task that hasn't exceeded
|
|
12
|
+
# max retries, claims it for the given agent, and returns the updated
|
|
13
|
+
# Task. Returns nil if no work is available.
|
|
12
14
|
#
|
|
13
15
|
# @param task_manager [#db, #update_task, #list_tasks] task persistence layer
|
|
14
16
|
# @param agent_name [String] unique identifier of the claiming agent
|
|
17
|
+
# @param max_retries [Integer] maximum retry count before skipping a task
|
|
15
18
|
# @return [Tasks::Task, nil] the claimed task, or nil if none available
|
|
16
|
-
def self.call(task_manager:, agent_name:)
|
|
19
|
+
def self.call(task_manager:, agent_name:, max_retries: MAX_RETRIES)
|
|
17
20
|
db = task_manager.db
|
|
18
|
-
claim_next_pending_task(db, agent_name)
|
|
21
|
+
claim_next_pending_task(db, agent_name, max_retries)
|
|
19
22
|
fetch_claimed_task(db, agent_name)
|
|
20
23
|
rescue StandardError => e
|
|
21
24
|
RubynCode.logger.warn("TaskClaimer: failed to claim task: #{e.message}") if RubynCode.respond_to?(:logger)
|
|
@@ -25,17 +28,20 @@ module RubynCode
|
|
|
25
28
|
class << self
|
|
26
29
|
private
|
|
27
30
|
|
|
28
|
-
def claim_next_pending_task(db, agent_name)
|
|
29
|
-
db.execute(<<~SQL, [agent_name])
|
|
31
|
+
def claim_next_pending_task(db, agent_name, max_retries)
|
|
32
|
+
db.execute(<<~SQL, [agent_name, max_retries])
|
|
30
33
|
UPDATE tasks
|
|
31
34
|
SET owner = ?,
|
|
32
35
|
status = 'in_progress',
|
|
33
36
|
updated_at = datetime('now')
|
|
34
37
|
WHERE id = (
|
|
35
|
-
SELECT id FROM tasks
|
|
36
|
-
WHERE status = 'pending'
|
|
37
|
-
AND (owner IS NULL OR owner = '')
|
|
38
|
-
|
|
38
|
+
SELECT t.id FROM tasks t
|
|
39
|
+
WHERE t.status = 'pending'
|
|
40
|
+
AND (t.owner IS NULL OR t.owner = '')
|
|
41
|
+
AND COALESCE(
|
|
42
|
+
json_extract(t.metadata, '$.retry_count'), 0
|
|
43
|
+
) < ?
|
|
44
|
+
ORDER BY t.priority DESC, t.created_at ASC
|
|
39
45
|
LIMIT 1
|
|
40
46
|
)
|
|
41
47
|
AND status = 'pending'
|
data/lib/rubyn_code/cli/app.rb
CHANGED
|
@@ -26,6 +26,8 @@ module RubynCode
|
|
|
26
26
|
rubyn-code --resume [ID] Resume a previous session
|
|
27
27
|
rubyn-code --setup Pin rubyn-code to bypass rbenv/rvm
|
|
28
28
|
rubyn-code --auth Authenticate with Claude
|
|
29
|
+
rubyn-code --ide Start IDE server (VS Code extension)
|
|
30
|
+
rubyn-code --permission-mode MODE Set permission mode (default, accept_edits, plan_only, auto, dont_ask, bypass)
|
|
29
31
|
rubyn-code --version Show version
|
|
30
32
|
rubyn-code --help Show this help
|
|
31
33
|
|
|
@@ -57,7 +59,8 @@ module RubynCode
|
|
|
57
59
|
'--help' => :help, '-h' => :help,
|
|
58
60
|
'--auth' => :auth, '--setup' => :setup
|
|
59
61
|
}.freeze
|
|
60
|
-
BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug }.freeze
|
|
62
|
+
BOOLEAN_FLAGS = { '--yolo' => :yolo, '--debug' => :debug, '--skip-setup' => :skip_setup, '--ide' => :ide }.freeze
|
|
63
|
+
VALUE_FLAGS = { '--permission-mode' => :permission_mode }.freeze
|
|
61
64
|
DAEMON_INT_FLAGS = { '--max-runs' => :max_runs, '--idle-timeout' => :idle_timeout,
|
|
62
65
|
'--poll-interval' => :poll_interval }.freeze
|
|
63
66
|
DAEMON_STR_FLAGS = { '--name' => :agent_name, '--role' => :role }.freeze
|
|
@@ -71,6 +74,7 @@ module RubynCode
|
|
|
71
74
|
when :setup then run_setup
|
|
72
75
|
when :help then display_help
|
|
73
76
|
when :run then run_single_prompt(@options[:prompt])
|
|
77
|
+
when :ide then run_ide
|
|
74
78
|
when :daemon then run_daemon
|
|
75
79
|
when :repl then run_repl
|
|
76
80
|
end
|
|
@@ -83,6 +87,7 @@ module RubynCode
|
|
|
83
87
|
idx = parse_single_option(argv, idx, options)
|
|
84
88
|
idx += 1
|
|
85
89
|
end
|
|
90
|
+
options[:command] = :ide if options[:ide]
|
|
86
91
|
options
|
|
87
92
|
end
|
|
88
93
|
|
|
@@ -93,6 +98,9 @@ module RubynCode
|
|
|
93
98
|
options[:command] = SIMPLE_FLAGS[arg]
|
|
94
99
|
elsif BOOLEAN_FLAGS.key?(arg)
|
|
95
100
|
options[BOOLEAN_FLAGS[arg]] = true
|
|
101
|
+
elsif VALUE_FLAGS.key?(arg)
|
|
102
|
+
options[VALUE_FLAGS[arg]] = argv[idx + 1]
|
|
103
|
+
idx += 1
|
|
96
104
|
else
|
|
97
105
|
idx = parse_value_option(argv, idx, options)
|
|
98
106
|
end
|
|
@@ -188,11 +196,17 @@ module RubynCode
|
|
|
188
196
|
puts response
|
|
189
197
|
end
|
|
190
198
|
|
|
199
|
+
def run_ide
|
|
200
|
+
mode = resolve_permission_mode
|
|
201
|
+
IDE::Server.new(permission_mode: mode).run
|
|
202
|
+
end
|
|
203
|
+
|
|
191
204
|
def run_daemon
|
|
192
205
|
DaemonRunner.new(@options).run
|
|
193
206
|
end
|
|
194
207
|
|
|
195
208
|
def run_repl
|
|
209
|
+
maybe_first_run!
|
|
196
210
|
REPL.new(
|
|
197
211
|
session_id: @options[:session_id],
|
|
198
212
|
project_root: Dir.pwd,
|
|
@@ -200,6 +214,23 @@ module RubynCode
|
|
|
200
214
|
).run
|
|
201
215
|
end
|
|
202
216
|
|
|
217
|
+
def maybe_first_run!
|
|
218
|
+
return unless FirstRun.needed?
|
|
219
|
+
return if FirstRun.skipped?(skip_flag: @options[:skip_setup])
|
|
220
|
+
|
|
221
|
+
FirstRun.new.run
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def resolve_permission_mode
|
|
225
|
+
if @options[:permission_mode]
|
|
226
|
+
@options[:permission_mode].to_sym
|
|
227
|
+
elsif @options[:yolo]
|
|
228
|
+
:bypass
|
|
229
|
+
else
|
|
230
|
+
:default
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
203
234
|
def display_help
|
|
204
235
|
puts HELP_TEXT
|
|
205
236
|
end
|
|
@@ -14,6 +14,9 @@ module RubynCode
|
|
|
14
14
|
check_auth
|
|
15
15
|
check_skills
|
|
16
16
|
check_project
|
|
17
|
+
check_mcp
|
|
18
|
+
check_codebase_index
|
|
19
|
+
check_skill_catalog
|
|
17
20
|
].freeze
|
|
18
21
|
|
|
19
22
|
def execute(_args, ctx)
|
|
@@ -97,6 +100,76 @@ module RubynCode
|
|
|
97
100
|
['Project detected', true, "#{type} at #{ctx.project_root}"]
|
|
98
101
|
end
|
|
99
102
|
|
|
103
|
+
def check_mcp(ctx)
|
|
104
|
+
config_path = File.join(ctx.project_root, MCP::Config::CONFIG_FILENAME)
|
|
105
|
+
return ['MCP connectivity', false, 'mcp.json not found'] unless File.exist?(config_path)
|
|
106
|
+
|
|
107
|
+
servers = MCP::Config.load(ctx.project_root)
|
|
108
|
+
return ['MCP connectivity', false, 'no servers configured'] if servers.empty?
|
|
109
|
+
|
|
110
|
+
reachable = servers.count { |s| mcp_server_reachable?(s) }
|
|
111
|
+
detail = "#{reachable}/#{servers.size} servers reachable"
|
|
112
|
+
['MCP connectivity', reachable == servers.size, detail]
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
['MCP connectivity', false, e.message]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def mcp_server_reachable?(server)
|
|
118
|
+
command = server[:command]
|
|
119
|
+
return false if command.nil? || command.empty?
|
|
120
|
+
|
|
121
|
+
# Check if the command binary exists on PATH
|
|
122
|
+
system("command -v #{command} > /dev/null 2>&1")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def check_codebase_index(ctx)
|
|
126
|
+
index_path = File.join(ctx.project_root, Index::CodebaseIndex::INDEX_DIR,
|
|
127
|
+
Index::CodebaseIndex::INDEX_FILE)
|
|
128
|
+
return ['Codebase index', false, 'index not found'] unless File.exist?(index_path)
|
|
129
|
+
|
|
130
|
+
mtime = File.mtime(index_path)
|
|
131
|
+
age_hours = ((Time.now - mtime) / 3600).round(1)
|
|
132
|
+
stale = age_hours > 24
|
|
133
|
+
detail = "#{age_hours}h old#{' (stale — consider reindexing)' if stale}"
|
|
134
|
+
['Codebase index', !stale, detail]
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
['Codebase index', false, e.message]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def check_skill_catalog(ctx)
|
|
140
|
+
catalog = ctx.skill_loader.catalog
|
|
141
|
+
entries = catalog.available
|
|
142
|
+
return ['Skill catalog', false, 'no skills found'] if entries.empty?
|
|
143
|
+
|
|
144
|
+
malformed = count_malformed_skills(catalog.skills_dirs)
|
|
145
|
+
detail = "#{entries.size} skills loaded"
|
|
146
|
+
detail += ", #{malformed} malformed" if malformed.positive?
|
|
147
|
+
['Skill catalog', malformed.zero?, detail]
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
['Skill catalog', false, e.message]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def count_malformed_skills(skills_dirs)
|
|
153
|
+
count = 0
|
|
154
|
+
skills_dirs.each do |dir|
|
|
155
|
+
next unless File.directory?(dir)
|
|
156
|
+
|
|
157
|
+
Dir.glob(File.join(dir, '**/*.md')).each do |path|
|
|
158
|
+
count += 1 unless valid_skill_file?(path)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
count
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def valid_skill_file?(path)
|
|
165
|
+
content = File.read(path, 1024, encoding: 'UTF-8')
|
|
166
|
+
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
167
|
+
doc = Skills::Document.parse(content, filename: path)
|
|
168
|
+
!doc.name.nil? && !doc.name.empty?
|
|
169
|
+
rescue StandardError
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
100
173
|
def detect_project_type(root)
|
|
101
174
|
return 'Rails' if File.exist?(File.join(root, 'config', 'application.rb'))
|
|
102
175
|
return 'Ruby' if File.exist?(File.join(root, 'Rakefile'))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module CLI
|
|
5
|
+
module Commands
|
|
6
|
+
class Mcp < Base
|
|
7
|
+
def self.command_name = '/mcp'
|
|
8
|
+
def self.description = 'MCP server status'
|
|
9
|
+
|
|
10
|
+
def execute(_args, ctx)
|
|
11
|
+
configs = load_configs(ctx.project_root)
|
|
12
|
+
|
|
13
|
+
if configs.empty?
|
|
14
|
+
ctx.renderer.info('No MCP servers configured.')
|
|
15
|
+
puts ' Add servers to .rubyn-code/mcp.json — see docs/MCP.md for details.'
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
ctx.renderer.info("MCP servers (#{configs.size}):")
|
|
20
|
+
puts
|
|
21
|
+
|
|
22
|
+
configs.each { |cfg| render_server(cfg) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def load_configs(project_root)
|
|
28
|
+
MCP::Config.load(project_root)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_server(cfg)
|
|
32
|
+
client = build_client(cfg)
|
|
33
|
+
status, tool_count = probe_server(client)
|
|
34
|
+
icon = status_icon(status)
|
|
35
|
+
tools_label = tool_count ? " (#{tool_count} tools)" : ''
|
|
36
|
+
|
|
37
|
+
puts " #{icon} #{cfg[:name]} [#{status}]#{tools_label}"
|
|
38
|
+
render_transport_info(cfg)
|
|
39
|
+
ensure
|
|
40
|
+
client&.disconnect! if client&.connected?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_client(cfg)
|
|
44
|
+
MCP::Client.from_config(cfg)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def probe_server(client)
|
|
48
|
+
client.connect!
|
|
49
|
+
tool_count = client.tools.size
|
|
50
|
+
[:connected, tool_count]
|
|
51
|
+
rescue StandardError
|
|
52
|
+
[:error, nil]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def render_transport_info(cfg)
|
|
56
|
+
if cfg[:url]
|
|
57
|
+
puts " transport: SSE url: #{cfg[:url]}"
|
|
58
|
+
else
|
|
59
|
+
puts " transport: stdio command: #{cfg[:command]} #{cfg[:args].join(' ')}".rstrip
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status_icon(status)
|
|
64
|
+
case status
|
|
65
|
+
when :connected then green('*')
|
|
66
|
+
when :error then red('x')
|
|
67
|
+
else yellow('?')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def green(text) = "\e[32m#{text}\e[0m"
|
|
72
|
+
def red(text) = "\e[31m#{text}\e[0m"
|
|
73
|
+
def yellow(text) = "\e[33m#{text}\e[0m"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -68,7 +68,28 @@ module RubynCode
|
|
|
68
68
|
current = client.model
|
|
69
69
|
ctx.renderer.info("Provider: #{provider}")
|
|
70
70
|
ctx.renderer.info("Current model: #{current}")
|
|
71
|
-
|
|
71
|
+
ctx.renderer.info("Available: #{client.models.join(', ')}")
|
|
72
|
+
show_other_providers(provider, ctx)
|
|
73
|
+
ctx.renderer.info('Tip: /model provider:model to switch providers (e.g., /model openai:gpt-4o)')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def show_other_providers(current_provider, ctx)
|
|
77
|
+
others = other_provider_entries(current_provider)
|
|
78
|
+
return if others.empty?
|
|
79
|
+
|
|
80
|
+
ctx.renderer.info('')
|
|
81
|
+
ctx.renderer.info('Other providers:')
|
|
82
|
+
others.each { |label| ctx.renderer.info(" #{label}") }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def other_provider_entries(current_provider)
|
|
86
|
+
providers = Config::Settings.new.data['providers']
|
|
87
|
+
return [] unless providers.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
providers.reject { |name, _| name == current_provider }.map do |name, cfg|
|
|
90
|
+
models = extract_config_models(cfg)
|
|
91
|
+
models.empty? ? name : "#{name}: #{models.join(', ')}"
|
|
92
|
+
end
|
|
72
93
|
end
|
|
73
94
|
|
|
74
95
|
def show_available(ctx)
|
|
@@ -85,9 +106,18 @@ module RubynCode
|
|
|
85
106
|
case provider
|
|
86
107
|
when 'anthropic' then LLM::Adapters::Anthropic::AVAILABLE_MODELS
|
|
87
108
|
when 'openai' then LLM::Adapters::OpenAI::AVAILABLE_MODELS
|
|
88
|
-
else
|
|
109
|
+
else
|
|
110
|
+
cfg = Config::Settings.new.provider_config(provider)
|
|
111
|
+
extract_config_models(cfg)
|
|
89
112
|
end
|
|
90
113
|
end
|
|
114
|
+
|
|
115
|
+
def extract_config_models(cfg)
|
|
116
|
+
raw = cfg&.dig('models')
|
|
117
|
+
return [] unless raw
|
|
118
|
+
|
|
119
|
+
raw.is_a?(Hash) ? raw.values : Array(raw)
|
|
120
|
+
end
|
|
91
121
|
end
|
|
92
122
|
end
|
|
93
123
|
end
|