rubyn-code 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +269 -467
- data/db/migrations/009_create_teams.sql +6 -6
- data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
- data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
- data/exe/rubyn-code +1 -1
- data/lib/rubyn_code/agent/RUBYN.md +17 -0
- data/lib/rubyn_code/agent/conversation.rb +68 -19
- data/lib/rubyn_code/agent/loop.rb +312 -54
- data/lib/rubyn_code/agent/loop_detector.rb +6 -6
- data/lib/rubyn_code/auth/RUBYN.md +19 -0
- data/lib/rubyn_code/auth/oauth.rb +40 -35
- data/lib/rubyn_code/auth/server.rb +16 -12
- data/lib/rubyn_code/auth/token_store.rb +22 -22
- data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
- data/lib/rubyn_code/autonomous/daemon.rb +115 -79
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
- data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
- data/lib/rubyn_code/background/RUBYN.md +13 -0
- data/lib/rubyn_code/background/notifier.rb +0 -2
- data/lib/rubyn_code/background/worker.rb +60 -15
- data/lib/rubyn_code/cli/RUBYN.md +30 -0
- data/lib/rubyn_code/cli/app.rb +85 -9
- data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
- data/lib/rubyn_code/cli/commands/base.rb +53 -0
- data/lib/rubyn_code/cli/commands/budget.rb +24 -0
- data/lib/rubyn_code/cli/commands/clear.rb +16 -0
- data/lib/rubyn_code/cli/commands/compact.rb +21 -0
- data/lib/rubyn_code/cli/commands/context.rb +44 -0
- data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
- data/lib/rubyn_code/cli/commands/cost.rb +23 -0
- data/lib/rubyn_code/cli/commands/diff.rb +30 -0
- data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
- data/lib/rubyn_code/cli/commands/help.rb +41 -0
- data/lib/rubyn_code/cli/commands/model.rb +37 -0
- data/lib/rubyn_code/cli/commands/plan.rb +22 -0
- data/lib/rubyn_code/cli/commands/quit.rb +17 -0
- data/lib/rubyn_code/cli/commands/registry.rb +64 -0
- data/lib/rubyn_code/cli/commands/resume.rb +51 -0
- data/lib/rubyn_code/cli/commands/review.rb +26 -0
- data/lib/rubyn_code/cli/commands/skill.rb +32 -0
- data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
- data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
- data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
- data/lib/rubyn_code/cli/commands/undo.rb +17 -0
- data/lib/rubyn_code/cli/commands/version.rb +16 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
- data/lib/rubyn_code/cli/input_handler.rb +20 -23
- data/lib/rubyn_code/cli/renderer.rb +25 -27
- data/lib/rubyn_code/cli/repl.rb +161 -194
- data/lib/rubyn_code/cli/setup.rb +117 -0
- data/lib/rubyn_code/cli/spinner.rb +40 -40
- data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
- data/lib/rubyn_code/cli/version_check.rb +94 -0
- data/lib/rubyn_code/config/RUBYN.md +14 -0
- data/lib/rubyn_code/config/defaults.rb +28 -19
- data/lib/rubyn_code/config/project_config.rb +7 -9
- data/lib/rubyn_code/config/settings.rb +3 -3
- data/lib/rubyn_code/context/RUBYN.md +20 -0
- data/lib/rubyn_code/context/auto_compact.rb +7 -7
- data/lib/rubyn_code/context/compactor.rb +2 -2
- data/lib/rubyn_code/context/context_collapse.rb +45 -0
- data/lib/rubyn_code/context/manager.rb +20 -3
- data/lib/rubyn_code/context/manual_compact.rb +7 -7
- data/lib/rubyn_code/context/micro_compact.rb +12 -12
- data/lib/rubyn_code/db/RUBYN.md +40 -0
- data/lib/rubyn_code/db/connection.rb +13 -13
- data/lib/rubyn_code/db/migrator.rb +67 -27
- data/lib/rubyn_code/db/schema.rb +6 -6
- data/lib/rubyn_code/debug.rb +74 -0
- data/lib/rubyn_code/hooks/RUBYN.md +17 -0
- data/lib/rubyn_code/hooks/built_in.rb +9 -9
- data/lib/rubyn_code/hooks/registry.rb +5 -5
- data/lib/rubyn_code/hooks/runner.rb +1 -1
- data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
- data/lib/rubyn_code/learning/RUBYN.md +16 -0
- data/lib/rubyn_code/learning/extractor.rb +22 -22
- data/lib/rubyn_code/learning/injector.rb +17 -18
- data/lib/rubyn_code/learning/instinct.rb +18 -14
- data/lib/rubyn_code/llm/RUBYN.md +15 -0
- data/lib/rubyn_code/llm/client.rb +121 -55
- data/lib/rubyn_code/llm/message_builder.rb +19 -15
- data/lib/rubyn_code/llm/streaming.rb +80 -50
- data/lib/rubyn_code/mcp/RUBYN.md +21 -0
- data/lib/rubyn_code/mcp/client.rb +25 -24
- data/lib/rubyn_code/mcp/config.rb +7 -7
- data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
- data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
- data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
- data/lib/rubyn_code/memory/RUBYN.md +17 -0
- data/lib/rubyn_code/memory/models.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +17 -17
- data/lib/rubyn_code/memory/session_persistence.rb +49 -34
- data/lib/rubyn_code/memory/store.rb +17 -17
- data/lib/rubyn_code/observability/RUBYN.md +19 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
- data/lib/rubyn_code/observability/token_counter.rb +1 -1
- data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
- data/lib/rubyn_code/output/RUBYN.md +11 -0
- data/lib/rubyn_code/output/diff_renderer.rb +6 -6
- data/lib/rubyn_code/output/formatter.rb +4 -4
- data/lib/rubyn_code/permissions/RUBYN.md +17 -0
- data/lib/rubyn_code/permissions/prompter.rb +8 -8
- data/lib/rubyn_code/protocols/RUBYN.md +14 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
- data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
- data/lib/rubyn_code/skills/RUBYN.md +19 -0
- data/lib/rubyn_code/skills/catalog.rb +7 -7
- data/lib/rubyn_code/skills/document.rb +15 -15
- data/lib/rubyn_code/skills/loader.rb +6 -8
- data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
- data/lib/rubyn_code/sub_agents/runner.rb +15 -15
- data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
- data/lib/rubyn_code/tasks/RUBYN.md +13 -0
- data/lib/rubyn_code/tasks/dag.rb +12 -16
- data/lib/rubyn_code/tasks/manager.rb +24 -24
- data/lib/rubyn_code/tasks/models.rb +4 -4
- data/lib/rubyn_code/teams/RUBYN.md +14 -0
- data/lib/rubyn_code/teams/mailbox.rb +38 -18
- data/lib/rubyn_code/teams/manager.rb +19 -19
- data/lib/rubyn_code/teams/teammate.rb +3 -4
- data/lib/rubyn_code/tools/RUBYN.md +38 -0
- data/lib/rubyn_code/tools/background_run.rb +9 -11
- data/lib/rubyn_code/tools/base.rb +54 -3
- data/lib/rubyn_code/tools/bash.rb +16 -34
- data/lib/rubyn_code/tools/bundle_add.rb +10 -12
- data/lib/rubyn_code/tools/bundle_install.rb +9 -11
- data/lib/rubyn_code/tools/compact.rb +10 -9
- data/lib/rubyn_code/tools/db_migrate.rb +17 -15
- data/lib/rubyn_code/tools/edit_file.rb +12 -12
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/git_commit.rb +29 -34
- data/lib/rubyn_code/tools/git_diff.rb +17 -18
- data/lib/rubyn_code/tools/git_log.rb +17 -19
- data/lib/rubyn_code/tools/git_status.rb +18 -20
- data/lib/rubyn_code/tools/glob.rb +7 -9
- data/lib/rubyn_code/tools/grep.rb +11 -9
- data/lib/rubyn_code/tools/load_skill.rb +7 -7
- data/lib/rubyn_code/tools/memory_search.rb +13 -12
- data/lib/rubyn_code/tools/memory_write.rb +14 -12
- data/lib/rubyn_code/tools/rails_generate.rb +16 -16
- data/lib/rubyn_code/tools/read_file.rb +8 -7
- data/lib/rubyn_code/tools/read_inbox.rb +5 -5
- data/lib/rubyn_code/tools/registry.rb +2 -2
- data/lib/rubyn_code/tools/review_pr.rb +55 -55
- data/lib/rubyn_code/tools/run_specs.rb +20 -19
- data/lib/rubyn_code/tools/schema.rb +9 -11
- data/lib/rubyn_code/tools/send_message.rb +10 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
- data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
- data/lib/rubyn_code/tools/task.rb +28 -28
- data/lib/rubyn_code/tools/web_fetch.rb +46 -31
- data/lib/rubyn_code/tools/web_search.rb +64 -66
- data/lib/rubyn_code/tools/write_file.rb +7 -6
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +136 -105
- metadata +94 -21
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
7
|
-
require
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require 'faraday'
|
|
7
|
+
require 'json'
|
|
8
8
|
|
|
9
9
|
module RubynCode
|
|
10
10
|
module Auth
|
|
11
11
|
class OAuth
|
|
12
|
-
StateMismatchError
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
class StateMismatchError < RubynCode::AuthenticationError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class TokenExchangeError < RubynCode::AuthenticationError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class RefreshError < RubynCode::AuthenticationError
|
|
19
|
+
end
|
|
15
20
|
|
|
16
21
|
VERIFIER_LENGTH = 43
|
|
17
22
|
|
|
@@ -28,7 +33,7 @@ module RubynCode
|
|
|
28
33
|
result = callback_server.wait_for_callback(timeout: 120)
|
|
29
34
|
|
|
30
35
|
unless secure_compare(result[:state], state)
|
|
31
|
-
raise StateMismatchError,
|
|
36
|
+
raise StateMismatchError, 'OAuth state parameter mismatch — possible CSRF attack'
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
tokens = exchange_code(code: result[:code], code_verifier:)
|
|
@@ -44,12 +49,12 @@ module RubynCode
|
|
|
44
49
|
|
|
45
50
|
def refresh!
|
|
46
51
|
stored = TokenStore.load
|
|
47
|
-
raise RefreshError,
|
|
52
|
+
raise RefreshError, 'No stored refresh token available' unless stored&.dig(:refresh_token)
|
|
48
53
|
|
|
49
54
|
response = http_client.post(token_url) do |req|
|
|
50
|
-
req.headers[
|
|
55
|
+
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
51
56
|
req.body = URI.encode_www_form(
|
|
52
|
-
grant_type:
|
|
57
|
+
grant_type: 'refresh_token',
|
|
53
58
|
client_id: client_id,
|
|
54
59
|
refresh_token: stored[:refresh_token]
|
|
55
60
|
)
|
|
@@ -57,23 +62,23 @@ module RubynCode
|
|
|
57
62
|
|
|
58
63
|
unless response.success?
|
|
59
64
|
body = parse_json(response.body)
|
|
60
|
-
error_msg = body&.dig(
|
|
65
|
+
error_msg = body&.dig('error_description') || body&.dig('error') || response.body
|
|
61
66
|
raise RefreshError, "Token refresh failed (#{response.status}): #{error_msg}"
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
body = parse_json(response.body)
|
|
65
|
-
raise RefreshError,
|
|
70
|
+
raise RefreshError, 'Invalid response from token endpoint' unless body
|
|
66
71
|
|
|
67
72
|
TokenStore.save(
|
|
68
|
-
access_token: body[
|
|
69
|
-
refresh_token: body[
|
|
70
|
-
expires_at: Time.now + body[
|
|
73
|
+
access_token: body['access_token'],
|
|
74
|
+
refresh_token: body['refresh_token'] || stored[:refresh_token],
|
|
75
|
+
expires_at: Time.now + body['expires_in'].to_i
|
|
71
76
|
)
|
|
72
77
|
|
|
73
78
|
{
|
|
74
|
-
access_token: body[
|
|
75
|
-
refresh_token: body[
|
|
76
|
-
expires_in: body[
|
|
79
|
+
access_token: body['access_token'],
|
|
80
|
+
refresh_token: body['refresh_token'] || stored[:refresh_token],
|
|
81
|
+
expires_in: body['expires_in']
|
|
77
82
|
}
|
|
78
83
|
end
|
|
79
84
|
|
|
@@ -90,13 +95,13 @@ module RubynCode
|
|
|
90
95
|
|
|
91
96
|
def build_authorization_url(code_challenge:, state:)
|
|
92
97
|
params = URI.encode_www_form(
|
|
93
|
-
response_type:
|
|
98
|
+
response_type: 'code',
|
|
94
99
|
client_id: client_id,
|
|
95
100
|
redirect_uri: redirect_uri,
|
|
96
101
|
scope: scopes,
|
|
97
102
|
state: state,
|
|
98
103
|
code_challenge: code_challenge,
|
|
99
|
-
code_challenge_method:
|
|
104
|
+
code_challenge_method: 'S256'
|
|
100
105
|
)
|
|
101
106
|
|
|
102
107
|
"#{authorize_url}?#{params}"
|
|
@@ -104,9 +109,9 @@ module RubynCode
|
|
|
104
109
|
|
|
105
110
|
def exchange_code(code:, code_verifier:)
|
|
106
111
|
response = http_client.post(token_url) do |req|
|
|
107
|
-
req.headers[
|
|
112
|
+
req.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
108
113
|
req.body = URI.encode_www_form(
|
|
109
|
-
grant_type:
|
|
114
|
+
grant_type: 'authorization_code',
|
|
110
115
|
client_id: client_id,
|
|
111
116
|
code: code,
|
|
112
117
|
redirect_uri: redirect_uri,
|
|
@@ -116,26 +121,26 @@ module RubynCode
|
|
|
116
121
|
|
|
117
122
|
unless response.success?
|
|
118
123
|
body = parse_json(response.body)
|
|
119
|
-
error_msg = body&.dig(
|
|
124
|
+
error_msg = body&.dig('error_description') || body&.dig('error') || response.body
|
|
120
125
|
raise TokenExchangeError, "Code exchange failed (#{response.status}): #{error_msg}"
|
|
121
126
|
end
|
|
122
127
|
|
|
123
128
|
body = parse_json(response.body)
|
|
124
|
-
raise TokenExchangeError,
|
|
129
|
+
raise TokenExchangeError, 'Invalid response from token endpoint' unless body
|
|
125
130
|
|
|
126
131
|
{
|
|
127
|
-
access_token: body[
|
|
128
|
-
refresh_token: body[
|
|
129
|
-
expires_in: body[
|
|
132
|
+
access_token: body['access_token'],
|
|
133
|
+
refresh_token: body['refresh_token'],
|
|
134
|
+
expires_in: body['expires_in']
|
|
130
135
|
}
|
|
131
136
|
end
|
|
132
137
|
|
|
133
138
|
def open_browser(url)
|
|
134
139
|
launcher = case RUBY_PLATFORM
|
|
135
|
-
when /darwin/ then
|
|
136
|
-
when /linux/ then
|
|
137
|
-
when /mingw|mswin/ then
|
|
138
|
-
else
|
|
140
|
+
when /darwin/ then 'open'
|
|
141
|
+
when /linux/ then 'xdg-open'
|
|
142
|
+
when /mingw|mswin/ then 'start'
|
|
143
|
+
else 'xdg-open'
|
|
139
144
|
end
|
|
140
145
|
|
|
141
146
|
system(launcher, url, exception: false)
|
|
@@ -159,8 +164,8 @@ module RubynCode
|
|
|
159
164
|
return false if a.nil? || b.nil?
|
|
160
165
|
return false unless a.bytesize == b.bytesize
|
|
161
166
|
|
|
162
|
-
l = a.unpack(
|
|
163
|
-
r = b.unpack(
|
|
167
|
+
l = a.unpack('C*')
|
|
168
|
+
r = b.unpack('C*')
|
|
164
169
|
l.zip(r).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
|
|
165
170
|
end
|
|
166
171
|
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'webrick'
|
|
4
|
+
require 'uri'
|
|
5
5
|
|
|
6
6
|
module RubynCode
|
|
7
7
|
module Auth
|
|
8
8
|
class Server
|
|
9
|
-
LISTEN_HOST =
|
|
9
|
+
LISTEN_HOST = '127.0.0.1'
|
|
10
10
|
LISTEN_PORT = 19_275
|
|
11
11
|
|
|
12
|
-
CallbackTimeout
|
|
12
|
+
class CallbackTimeout < RubynCode::AuthenticationError
|
|
13
|
+
end
|
|
13
14
|
|
|
14
15
|
def initialize
|
|
15
16
|
@result = nil
|
|
@@ -46,7 +47,7 @@ module RubynCode
|
|
|
46
47
|
AccessLog: access_log
|
|
47
48
|
)
|
|
48
49
|
|
|
49
|
-
server.mount_proc(
|
|
50
|
+
server.mount_proc('/callback') do |req, res|
|
|
50
51
|
handle_callback(req, res, server)
|
|
51
52
|
end
|
|
52
53
|
|
|
@@ -55,8 +56,8 @@ module RubynCode
|
|
|
55
56
|
|
|
56
57
|
def handle_callback(req, res, server)
|
|
57
58
|
params = parse_query(req.query_string)
|
|
58
|
-
code = params[
|
|
59
|
-
state = params[
|
|
59
|
+
code = params['code']
|
|
60
|
+
state = params['state']
|
|
60
61
|
|
|
61
62
|
if code
|
|
62
63
|
@mutex.synchronize do
|
|
@@ -65,14 +66,14 @@ module RubynCode
|
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
res.status = 200
|
|
68
|
-
res.content_type =
|
|
69
|
+
res.content_type = 'text/html; charset=utf-8'
|
|
69
70
|
res.body = success_html
|
|
70
71
|
else
|
|
71
|
-
error = params[
|
|
72
|
-
description = params[
|
|
72
|
+
error = params['error'] || 'unknown'
|
|
73
|
+
description = params['error_description'] || 'No authorization code received'
|
|
73
74
|
|
|
74
75
|
res.status = 400
|
|
75
|
-
res.content_type =
|
|
76
|
+
res.content_type = 'text/html; charset=utf-8'
|
|
76
77
|
res.body = error_html(error, description)
|
|
77
78
|
|
|
78
79
|
@mutex.synchronize do
|
|
@@ -80,7 +81,10 @@ module RubynCode
|
|
|
80
81
|
end
|
|
81
82
|
end
|
|
82
83
|
|
|
83
|
-
Thread.new
|
|
84
|
+
Thread.new do
|
|
85
|
+
sleep(0.5)
|
|
86
|
+
server.shutdown
|
|
87
|
+
end
|
|
84
88
|
end
|
|
85
89
|
|
|
86
90
|
def parse_query(query_string)
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'time'
|
|
7
7
|
|
|
8
8
|
module RubynCode
|
|
9
9
|
module Auth
|
|
10
10
|
module TokenStore
|
|
11
11
|
EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
|
|
12
|
-
KEYCHAIN_SERVICE =
|
|
12
|
+
KEYCHAIN_SERVICE = 'Claude Code-credentials'
|
|
13
13
|
|
|
14
14
|
class << self
|
|
15
15
|
# Load tokens with fallback chain:
|
|
@@ -24,9 +24,9 @@ module RubynCode
|
|
|
24
24
|
ensure_directory!
|
|
25
25
|
|
|
26
26
|
data = {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
'access_token' => access_token,
|
|
28
|
+
'refresh_token' => refresh_token,
|
|
29
|
+
'expires_at' => expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
File.write(tokens_path, YAML.dump(data))
|
|
@@ -35,7 +35,7 @@ module RubynCode
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def clear!
|
|
38
|
-
|
|
38
|
+
FileUtils.rm_f(tokens_path)
|
|
39
39
|
true
|
|
40
40
|
end
|
|
41
41
|
|
|
@@ -71,22 +71,22 @@ module RubynCode
|
|
|
71
71
|
|
|
72
72
|
# Read Claude Code's OAuth token from macOS Keychain
|
|
73
73
|
def load_from_keychain
|
|
74
|
-
return nil unless RUBY_PLATFORM.include?(
|
|
74
|
+
return nil unless RUBY_PLATFORM.include?('darwin')
|
|
75
75
|
|
|
76
76
|
output = `security find-generic-password -s "#{KEYCHAIN_SERVICE}" -w 2>/dev/null`.strip
|
|
77
77
|
return nil if output.empty?
|
|
78
78
|
|
|
79
79
|
data = JSON.parse(output)
|
|
80
|
-
oauth = data[
|
|
81
|
-
return nil unless oauth && oauth[
|
|
80
|
+
oauth = data['claudeAiOauth']
|
|
81
|
+
return nil unless oauth && oauth['accessToken']
|
|
82
82
|
|
|
83
|
-
expires_at = if oauth[
|
|
84
|
-
Time.at(oauth[
|
|
83
|
+
expires_at = if oauth['expiresAt']
|
|
84
|
+
Time.at(oauth['expiresAt'] / 1000.0) # milliseconds to seconds
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
{
|
|
88
|
-
access_token: oauth[
|
|
89
|
-
refresh_token: oauth[
|
|
88
|
+
access_token: oauth['accessToken'],
|
|
89
|
+
refresh_token: oauth['refreshToken'],
|
|
90
90
|
expires_at: expires_at,
|
|
91
91
|
type: :oauth,
|
|
92
92
|
source: :keychain
|
|
@@ -101,12 +101,12 @@ module RubynCode
|
|
|
101
101
|
|
|
102
102
|
data = YAML.safe_load_file(tokens_path, permitted_classes: [Time])
|
|
103
103
|
return nil unless data.is_a?(Hash)
|
|
104
|
-
return nil unless data[
|
|
104
|
+
return nil unless data['access_token']
|
|
105
105
|
|
|
106
106
|
{
|
|
107
|
-
access_token: data[
|
|
108
|
-
refresh_token: data[
|
|
109
|
-
expires_at: parse_time(data[
|
|
107
|
+
access_token: data['access_token'],
|
|
108
|
+
refresh_token: data['refresh_token'],
|
|
109
|
+
expires_at: parse_time(data['expires_at']),
|
|
110
110
|
type: :oauth,
|
|
111
111
|
source: :file
|
|
112
112
|
}
|
|
@@ -116,7 +116,7 @@ module RubynCode
|
|
|
116
116
|
|
|
117
117
|
# Fall back to ANTHROPIC_API_KEY environment variable
|
|
118
118
|
def load_from_env
|
|
119
|
-
api_key = ENV
|
|
119
|
+
api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
|
|
120
120
|
return nil unless api_key && !api_key.empty?
|
|
121
121
|
|
|
122
122
|
{
|
|
@@ -134,7 +134,7 @@ module RubynCode
|
|
|
134
134
|
|
|
135
135
|
def ensure_directory!
|
|
136
136
|
dir = File.dirname(tokens_path)
|
|
137
|
-
FileUtils.mkdir_p(dir)
|
|
137
|
+
FileUtils.mkdir_p(dir)
|
|
138
138
|
File.chmod(0o700, dir)
|
|
139
139
|
end
|
|
140
140
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Layer 11: Autonomous
|
|
2
|
+
|
|
3
|
+
Daemon mode for hands-off task execution.
|
|
4
|
+
|
|
5
|
+
## Classes
|
|
6
|
+
|
|
7
|
+
- **`Daemon`** — Runs the agent in background mode. Polls for unclaimed tasks,
|
|
8
|
+
executes them, and reports results. No human in the loop.
|
|
9
|
+
|
|
10
|
+
- **`IdlePoller`** — Watches for new tasks at a configurable interval. Wakes the
|
|
11
|
+
daemon when work is available.
|
|
12
|
+
|
|
13
|
+
- **`TaskClaimer`** — Atomically claims tasks from the DAG to prevent double-execution
|
|
14
|
+
when multiple agents are running.
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'securerandom'
|
|
4
4
|
|
|
5
5
|
module RubynCode
|
|
6
6
|
module Autonomous
|
|
7
|
-
# The
|
|
7
|
+
# The GOLEM daemon — an always-on autonomous agent that cycles between
|
|
8
8
|
# working on tasks and polling for new work. The lifecycle is:
|
|
9
9
|
#
|
|
10
|
-
#
|
|
10
|
+
# spawned → working ⇄ idle → shutting_down → stopped
|
|
11
11
|
#
|
|
12
|
-
# Safety limits (max_runs, max_cost) prevent runaway execution.
|
|
12
|
+
# Safety limits (max_runs, max_cost, idle_timeout) prevent runaway execution.
|
|
13
|
+
# Signal traps (SIGTERM, SIGINT) trigger graceful shutdown.
|
|
14
|
+
#
|
|
15
|
+
# Unlike the REPL, the daemon runs a full Agent::Loop per task — meaning
|
|
16
|
+
# it can read files, write code, run specs, and use every tool available.
|
|
13
17
|
class Daemon
|
|
14
18
|
LIFECYCLE_STATES = %i[spawned working idle shutting_down stopped].freeze
|
|
15
19
|
|
|
@@ -19,32 +23,38 @@ module RubynCode
|
|
|
19
23
|
# @param role [String] the agent's role / persona description
|
|
20
24
|
# @param llm_client [LLM::Client] LLM API client
|
|
21
25
|
# @param project_root [String] path to the project being worked on
|
|
22
|
-
# @param task_manager [
|
|
23
|
-
# @param mailbox [
|
|
26
|
+
# @param task_manager [Tasks::Manager] task persistence layer
|
|
27
|
+
# @param mailbox [Teams::Mailbox] message mailbox
|
|
24
28
|
# @param max_runs [Integer] maximum work cycles before auto-shutdown (default 100)
|
|
25
29
|
# @param max_cost [Float] maximum cumulative LLM cost in USD before auto-shutdown (default 10.0)
|
|
26
30
|
# @param poll_interval [Numeric] idle polling interval in seconds (default 5)
|
|
27
31
|
# @param idle_timeout [Numeric] seconds of idle before shutdown (default 60)
|
|
28
32
|
# @param on_state_change [Proc, nil] callback invoked with (old_state, new_state)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@
|
|
37
|
-
@
|
|
38
|
-
@
|
|
39
|
-
@
|
|
40
|
-
@
|
|
41
|
-
@
|
|
33
|
+
# @param on_task_complete [Proc, nil] callback invoked with (task, result_text)
|
|
34
|
+
# @param on_task_error [Proc, nil] callback invoked with (task, error)
|
|
35
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
36
|
+
agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:,
|
|
37
|
+
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
|
|
39
|
+
)
|
|
40
|
+
@agent_name = agent_name
|
|
41
|
+
@role = role
|
|
42
|
+
@llm_client = llm_client
|
|
43
|
+
@project_root = File.expand_path(project_root)
|
|
44
|
+
@task_manager = task_manager
|
|
45
|
+
@mailbox = mailbox
|
|
46
|
+
@max_runs = max_runs
|
|
47
|
+
@max_cost = max_cost
|
|
48
|
+
@poll_interval = poll_interval
|
|
49
|
+
@idle_timeout = idle_timeout
|
|
42
50
|
@on_state_change = on_state_change
|
|
51
|
+
@on_task_complete = on_task_complete
|
|
52
|
+
@on_task_error = on_task_error
|
|
43
53
|
|
|
44
|
-
@state
|
|
45
|
-
@runs_completed
|
|
46
|
-
@total_cost
|
|
47
|
-
@stop_requested
|
|
54
|
+
@state = :spawned
|
|
55
|
+
@runs_completed = 0
|
|
56
|
+
@total_cost = 0.0
|
|
57
|
+
@stop_requested = false
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
# Enters the work-idle-work cycle. Blocks the calling thread until
|
|
@@ -52,6 +62,7 @@ module RubynCode
|
|
|
52
62
|
#
|
|
53
63
|
# @return [Symbol] the final state (:stopped)
|
|
54
64
|
def start!
|
|
65
|
+
install_signal_handlers!
|
|
55
66
|
transition_to(:working)
|
|
56
67
|
|
|
57
68
|
loop do
|
|
@@ -108,37 +119,85 @@ module RubynCode
|
|
|
108
119
|
|
|
109
120
|
private
|
|
110
121
|
|
|
111
|
-
#
|
|
122
|
+
# ── Signal handling ──────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def install_signal_handlers!
|
|
125
|
+
%w[INT TERM].each do |sig|
|
|
126
|
+
Signal.trap(sig) { stop! }
|
|
127
|
+
end
|
|
128
|
+
rescue ArgumentError
|
|
129
|
+
# Some signals not available on all platforms (e.g. Windows)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ── Work phase (full Agent::Loop) ────────────────────────────
|
|
133
|
+
|
|
134
|
+
# Executes a full agent loop for a single claimed task — with tools,
|
|
135
|
+
# context management, and budget enforcement.
|
|
112
136
|
#
|
|
113
137
|
# @param task [Tasks::Task]
|
|
114
138
|
# @return [void]
|
|
115
139
|
def run_work_phase(task)
|
|
116
140
|
transition_to(:working)
|
|
117
141
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
response = @llm_client.chat(
|
|
122
|
-
messages: conversation.to_api_format,
|
|
123
|
-
system: build_system_prompt
|
|
124
|
-
)
|
|
142
|
+
agent_loop = build_agent_loop
|
|
143
|
+
result_text = agent_loop.send_message(build_work_prompt(task))
|
|
125
144
|
|
|
126
|
-
|
|
145
|
+
# Accumulate cost from the budget enforcer
|
|
146
|
+
track_cost_from_enforcer(agent_loop)
|
|
127
147
|
|
|
128
148
|
# Mark the task as completed with the agent's result.
|
|
129
|
-
|
|
130
|
-
@
|
|
131
|
-
"UPDATE tasks SET status = 'completed', result = ?, updated_at = datetime('now') WHERE id = ?",
|
|
132
|
-
[result_text, task.id]
|
|
133
|
-
)
|
|
149
|
+
@task_manager.complete(task.id, result: result_text)
|
|
150
|
+
@on_task_complete&.call(task, result_text)
|
|
134
151
|
rescue StandardError => e
|
|
135
152
|
# On failure, release the task so another agent (or retry) can pick it up.
|
|
136
|
-
@task_manager.
|
|
137
|
-
|
|
138
|
-
|
|
153
|
+
@task_manager.update(task.id, status: 'pending', owner: nil, result: "Error: #{e.message}")
|
|
154
|
+
@on_task_error&.call(task, e)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Builds a fresh Agent::Loop wired with all the real tools.
|
|
158
|
+
# Each task gets its own conversation and context so they don't bleed.
|
|
159
|
+
#
|
|
160
|
+
# @return [Agent::Loop]
|
|
161
|
+
def build_agent_loop
|
|
162
|
+
conversation = Agent::Conversation.new
|
|
163
|
+
tool_executor = Tools::Executor.new(project_root: @project_root)
|
|
164
|
+
context_manager = Context::Manager.new
|
|
165
|
+
hook_runner = Hooks::Runner.new(registry: Hooks::Registry.new)
|
|
166
|
+
stall_detector = Agent::LoopDetector.new
|
|
167
|
+
|
|
168
|
+
# Wire dependencies the executor needs for sub-agents / background
|
|
169
|
+
tool_executor.llm_client = @llm_client
|
|
170
|
+
tool_executor.db = @task_manager.db
|
|
171
|
+
|
|
172
|
+
Agent::Loop.new(
|
|
173
|
+
llm_client: @llm_client,
|
|
174
|
+
tool_executor: tool_executor,
|
|
175
|
+
context_manager: context_manager,
|
|
176
|
+
hook_runner: hook_runner,
|
|
177
|
+
conversation: conversation,
|
|
178
|
+
permission_tier: :unrestricted,
|
|
179
|
+
stall_detector: stall_detector,
|
|
180
|
+
project_root: @project_root
|
|
139
181
|
)
|
|
140
182
|
end
|
|
141
183
|
|
|
184
|
+
# Accumulates cost tracked by the Agent::Loop's context manager.
|
|
185
|
+
#
|
|
186
|
+
# @param agent_loop [Agent::Loop]
|
|
187
|
+
# @return [void]
|
|
188
|
+
def track_cost_from_enforcer(agent_loop)
|
|
189
|
+
# The context manager tracks token usage; we extract cost if available.
|
|
190
|
+
# This is best-effort — the daemon's own total_cost is an approximation.
|
|
191
|
+
cm = agent_loop.instance_variable_get(:@context_manager)
|
|
192
|
+
return unless cm.respond_to?(:total_cost)
|
|
193
|
+
|
|
194
|
+
@total_cost += cm.total_cost.to_f
|
|
195
|
+
rescue StandardError
|
|
196
|
+
# Non-critical
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# ── Idle phase ───────────────────────────────────────────────
|
|
200
|
+
|
|
142
201
|
# Delegates to IdlePoller to wait for new work.
|
|
143
202
|
#
|
|
144
203
|
# @return [:resume, :shutdown, :interrupted]
|
|
@@ -156,6 +215,8 @@ module RubynCode
|
|
|
156
215
|
@idle_poller.poll!
|
|
157
216
|
end
|
|
158
217
|
|
|
218
|
+
# ── Lifecycle ────────────────────────────────────────────────
|
|
219
|
+
|
|
159
220
|
# Performs final shutdown bookkeeping.
|
|
160
221
|
#
|
|
161
222
|
# @return [Symbol] :stopped
|
|
@@ -184,49 +245,24 @@ module RubynCode
|
|
|
184
245
|
@on_state_change&.call(old_state, new_state)
|
|
185
246
|
end
|
|
186
247
|
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
# @param response [#usage] LLM response with usage data
|
|
190
|
-
# @return [void]
|
|
191
|
-
def track_cost(response)
|
|
192
|
-
return unless response.respond_to?(:usage) && response.usage.respond_to?(:cost)
|
|
193
|
-
|
|
194
|
-
@total_cost += response.usage.cost.to_f
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# Extracts the textual result from an LLM response.
|
|
198
|
-
#
|
|
199
|
-
# @param response [#content] LLM response
|
|
200
|
-
# @return [String]
|
|
201
|
-
def extract_result(response)
|
|
202
|
-
return "" unless response.respond_to?(:content)
|
|
203
|
-
|
|
204
|
-
case response.content
|
|
205
|
-
when String
|
|
206
|
-
response.content
|
|
207
|
-
when Array
|
|
208
|
-
text_blocks = response.content.select { |b| b.is_a?(Hash) && b[:type] == "text" }
|
|
209
|
-
text_blocks.map { |b| b[:text] }.join("\n")
|
|
210
|
-
else
|
|
211
|
-
response.content.to_s
|
|
212
|
-
end
|
|
213
|
-
end
|
|
248
|
+
# ── Prompts ──────────────────────────────────────────────────
|
|
214
249
|
|
|
215
250
|
# @param task [Tasks::Task]
|
|
216
251
|
# @return [String]
|
|
217
252
|
def build_work_prompt(task)
|
|
218
|
-
|
|
219
|
-
"
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
253
|
+
<<~PROMPT
|
|
254
|
+
You are working autonomously as daemon agent "#{@agent_name}".
|
|
255
|
+
Complete the following task using the tools available to you.
|
|
256
|
+
|
|
257
|
+
Title: #{task.title}
|
|
258
|
+
Description: #{task.description}
|
|
259
|
+
Priority: #{task.priority}
|
|
260
|
+
Task ID: #{task.id}
|
|
261
|
+
|
|
262
|
+
Work in the project at: #{@project_root}
|
|
263
|
+
Be thorough. Use tools to read, write, test, and verify your work.
|
|
264
|
+
When done, summarize what you did.
|
|
265
|
+
PROMPT
|
|
230
266
|
end
|
|
231
267
|
end
|
|
232
268
|
end
|
|
@@ -35,13 +35,9 @@ module RubynCode
|
|
|
35
35
|
return :shutdown if monotonic_now >= deadline
|
|
36
36
|
|
|
37
37
|
# Messages always take priority over tasks.
|
|
38
|
-
if has_pending_messages?
|
|
39
|
-
return :resume
|
|
40
|
-
end
|
|
38
|
+
return :resume if has_pending_messages?
|
|
41
39
|
|
|
42
|
-
if has_claimable_task?
|
|
43
|
-
return :resume
|
|
44
|
-
end
|
|
40
|
+
return :resume if has_claimable_task?
|
|
45
41
|
|
|
46
42
|
remaining = deadline - monotonic_now
|
|
47
43
|
return :shutdown if remaining <= 0
|
|
@@ -71,10 +67,10 @@ module RubynCode
|
|
|
71
67
|
|
|
72
68
|
# Only re-inject if the identity is not already present as the
|
|
73
69
|
# first user message.
|
|
74
|
-
first_user = messages.find { |m| m[:role] ==
|
|
70
|
+
first_user = messages.find { |m| m[:role] == 'user' }
|
|
75
71
|
return if first_user && first_user[:content].to_s.include?(identity[0, 100])
|
|
76
72
|
|
|
77
|
-
messages.unshift({ role:
|
|
73
|
+
messages.unshift({ role: 'user', content: identity })
|
|
78
74
|
end
|
|
79
75
|
|
|
80
76
|
private
|